comparison 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
comparison
equal deleted inserted replaced
4073:7c5654c54fed 4074:26b7ed2817da
1 #!/usr/bin/env python3
2
3
4 # helper class for making a SAT frontend
5 # Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 # from sat.core.i18n import _, D_
21 from libervia.backend.core.log import getLogger
22
23 log = getLogger(__name__)
24
25
26 from libervia.frontends.quick_frontend.constants import Const as C
27 from libervia.frontends.quick_frontend import quick_widgets
28 from libervia.frontends.tools import jid
29 from libervia.backend.tools.common import data_format
30
31 try:
32 # FIXME: to be removed when an acceptable solution is here
33 str("") # XXX: unicode doesn't exist in pyjamas
34 except (
35 TypeError,
36 AttributeError,
37 ): # Error raised is not the same depending on pyjsbuild options
38 str = str
39
40 ENTRY_CLS = None
41 COMMENTS_CLS = None
42
43
44 class Item(object):
45 """Manage all (meta)data of an item"""
46
47 def __init__(self, data):
48 """
49 @param data(dict): microblog data as return by bridge methods
50 if data values are not defined, set default values
51 """
52 self.id = data["id"]
53 self.title = data.get("title")
54 self.title_rich = None
55 self.title_xhtml = data.get("title_xhtml")
56 self.tags = data.get('tags', [])
57 self.content = data.get("content")
58 self.content_rich = None
59 self.content_xhtml = data.get("content_xhtml")
60 self.author = data["author"]
61 try:
62 author_jid = data["author_jid"]
63 self.author_jid = jid.JID(author_jid) if author_jid else None
64 except KeyError:
65 self.author_jid = None
66
67 self.author_verified = data.get("author_jid_verified", False)
68
69 try:
70 self.updated = float(
71 data["updated"]
72 ) # XXX: int doesn't work here (pyjamas bug)
73 except KeyError:
74 self.updated = None
75
76 try:
77 self.published = float(
78 data["published"]
79 ) # XXX: int doesn't work here (pyjamas bug)
80 except KeyError:
81 self.published = None
82
83 self.comments = data.get("comments")
84 try:
85 self.comments_service = jid.JID(data["comments_service"])
86 except KeyError:
87 self.comments_service = None
88 self.comments_node = data.get("comments_node")
89
90 # def loadComments(self):
91 # """Load all the comments"""
92 # index = str(main_entry.comments_count - main_entry.hidden_count)
93 # rsm = {'max': str(main_entry.hidden_count), 'index': index}
94 # self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm)
95
96
97 class EntriesManager(object):
98 """Class which manages list of (micro)blog entries"""
99
100 def __init__(self, manager):
101 """
102 @param manager (EntriesManager, None): parent EntriesManager
103 must be None for QuickBlog (and only for QuickBlog)
104 """
105 self.manager = manager
106 if manager is None:
107 self.blog = self
108 else:
109 self.blog = manager.blog
110 self.entries = []
111 self.edit_entry = None
112
113 @property
114 def level(self):
115 """indicate how deep is this entry in the tree
116
117 if level == -1, we have a QuickBlog
118 if level == 0, we have a main item
119 else we have a comment
120 """
121 level = -1
122 manager = self.manager
123 while manager is not None:
124 level += 1
125 manager = manager.manager
126 return level
127
128 def _add_mb_items(self, items_tuple, service=None, node=None):
129 """Add Microblog items to this panel
130 update is NOT called after addition
131
132 @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mb_get
133 """
134 items, metadata = items_tuple
135 for item in items:
136 self.add_entry(item, service=service, node=node, with_update=False)
137
138 def _add_mb_items_with_comments(self, items_tuple, service=None, node=None):
139 """Add Microblog items to this panel
140 update is NOT called after addition
141
142 @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mb_get
143 """
144 items, metadata = items_tuple
145 for item, comments in items:
146 self.add_entry(item, comments, service=service, node=node, with_update=False)
147
148 def add_entry(self, item=None, comments=None, service=None, node=None,
149 with_update=True, editable=False, edit_entry=False):
150 """Add a microblog entry
151
152 @param editable (bool): True if the entry can be modified
153 @param item (dict, None): blog item data, or None for an empty entry
154 @param comments (list, None): list of comments data if available
155 @param service (jid.JID, None): service where the entry is coming from
156 @param service (unicode, None): node hosting the entry
157 @param with_update (bool): if True, udpate is called with the new entry
158 @param edit_entry(bool): if True, will be in self.edit_entry instead of
159 self.entries, so it can be managed separately (e.g. first or last
160 entry regardless of sorting)
161 """
162 new_entry = ENTRY_CLS(self, item, comments, service=service, node=node)
163 new_entry.set_editable(editable)
164 if edit_entry:
165 self.edit_entry = new_entry
166 else:
167 self.entries.append(new_entry)
168 if with_update:
169 self.update()
170 return new_entry
171
172 def update(self, entry=None):
173 """Update the display with entries
174
175 @param entry (Entry, None): if not None, must be the new entry.
176 If None, all the items will be checked to update the display
177 """
178 # update is separated from add_entry to allow adding
179 # several entries at once, and updating at the end
180 raise NotImplementedError
181
182
183 class Entry(EntriesManager):
184 """Graphical representation of an Item
185 This class must be overriden by frontends"""
186
187 def __init__(
188 self, manager, item_data=None, comments_data=None, service=None, node=None
189 ):
190 """
191 @param blog(QuickBlog): the parent QuickBlog
192 @param manager(EntriesManager): the parent EntriesManager
193 @param item_data(dict, None): dict containing the blog item data, or None for an empty entry
194 @param comments_data(list, None): list of comments data
195 """
196 assert manager is not None
197 EntriesManager.__init__(self, manager)
198 self.service = service
199 self.node = node
200 self.editable = False
201 self.reset(item_data)
202 self.blog.id2entries[self.item.id] = self
203 if self.item.comments:
204 node_tuple = (self.item.comments_service, self.item.comments_node)
205 self.blog.node2entries.setdefault(node_tuple, []).append(self)
206
207 def reset(self, item_data):
208 """Reset the entry with given data
209
210 used during init (it's a set and not a reset then)
211 or later (e.g. message sent, or cancellation of an edition
212 @param idem_data(dict, None): data as in __init__
213 """
214 if item_data is None:
215 self.new = True
216 item_data = {
217 "id": None,
218 # TODO: find a best author value
219 "author": self.blog.host.whoami.node,
220 }
221 else:
222 self.new = False
223 self.item = Item(item_data)
224 self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid
225 if self.author_jid is None and self.service and self.service.node:
226 self.author_jid = self.service
227 self.mode = (
228 C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML
229 )
230
231 def refresh(self):
232 """Refresh the display when data have been modified"""
233 pass
234
235 def set_editable(self, editable=True):
236 """tell if the entry can be edited or not
237
238 @param editable(bool): True if the entry can be edited
239 """
240 # XXX: we don't use @property as property setter doesn't play well with pyjamas
241 raise NotImplementedError
242
243 def add_comments(self, comments_data):
244 """Add comments to this entry by calling add_entry repeatidly
245
246 @param comments_data(tuple): data as returned by mb_get_from_many*RTResults
247 """
248 # TODO: manage seperator between comments of coming from different services/nodes
249 for data in comments_data:
250 service, node, failure, comments, metadata = data
251 for comment in comments:
252 if not failure:
253 self.add_entry(comment, service=jid.JID(service), node=node)
254 else:
255 log.warning("getting comment failed: {}".format(failure))
256 self.update()
257
258 def send(self):
259 """Send entry according to parent QuickBlog configuration and current level"""
260
261 # keys to keep other than content*, title* and tag*
262 # FIXME: see how to avoid comments node hijacking (someone could bind his post to another post's comments node)
263 keys_to_keep = ("id", "comments", "author", "author_jid", "published")
264
265 mb_data = {}
266 for key in keys_to_keep:
267 value = getattr(self.item, key)
268 if value is not None:
269 mb_data[key] = str(value)
270
271 for prefix in ("content", "title"):
272 for suffix in ("", "_rich", "_xhtml"):
273 name = "{}{}".format(prefix, suffix)
274 value = getattr(self.item, name)
275 if value is not None:
276 mb_data[name] = value
277
278 mb_data['tags'] = self.item.tags
279
280 if self.blog.new_message_target not in (C.PUBLIC, C.GROUP):
281 raise NotImplementedError
282
283 if self.level == 0:
284 mb_data["allow_comments"] = True
285
286 if self.blog.new_message_target == C.GROUP:
287 mb_data['groups'] = list(self.blog.targets)
288
289 self.blog.host.bridge.mb_send(
290 str(self.service or ""),
291 self.node or "",
292 data_format.serialise(mb_data),
293 profile=self.blog.profile,
294 )
295
296 def delete(self):
297 """Remove this Entry from parent manager
298
299 This doesn't delete any entry in PubSub, just locally
300 all children entries will be recursively removed too
301 """
302 # XXX: named delete and not remove to avoid conflict with pyjamas
303 log.debug("deleting entry {}".format("EDIT ENTRY" if self.new else self.item.id))
304 for child in self.entries:
305 child.delete()
306 try:
307 self.manager.entries.remove(self)
308 except ValueError:
309 if self != self.manager.edit_entry:
310 log.error("Internal Error: entry not found in manager")
311 else:
312 self.manager.edit_entry = None
313 if not self.new:
314 # we must remove references to self
315 # in QuickBlog's dictionary
316 del self.blog.id2entries[self.item.id]
317 if self.item.comments:
318 comments_tuple = (self.item.comments_service, self.item.comments_node)
319 other_entries = self.blog.node2entries[comments_tuple].remove(self)
320 if not other_entries:
321 del self.blog.node2entries[comments_tuple]
322
323 def retract(self):
324 """Retract this item from microblog node
325
326 if there is a comments node, it will be purged too
327 """
328 # TODO: manage several comments nodes case.
329 if self.item.comments:
330 self.blog.host.bridge.ps_node_delete(
331 str(self.item.comments_service) or "",
332 self.item.comments_node,
333 profile=self.blog.profile,
334 )
335 self.blog.host.bridge.mb_retract(
336 str(self.service or ""),
337 self.node or "",
338 self.item.id,
339 profile=self.blog.profile,
340 )
341
342
343 class QuickBlog(EntriesManager, quick_widgets.QuickWidget):
344 def __init__(self, host, targets, profiles=None):
345 """Panel used to show microblog
346
347 @param targets (tuple(unicode)): contact groups displayed in this panel.
348 If empty, show all microblogs from all contacts. targets is also used
349 to know where to send new messages.
350 """
351 EntriesManager.__init__(self, None)
352 self.id2entries = {} # used to find an entry with it's item id
353 # must be kept up-to-date by Entry
354 self.node2entries = {} # same as above, values are lists in case of
355 # two entries link to the same comments node
356 if not targets:
357 targets = () # XXX: we use empty tuple instead of None to workaround a pyjamas bug
358 quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE)
359 self._targets_type = C.ALL
360 else:
361 assert isinstance(targets[0], str)
362 quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE)
363 for target in targets[1:]:
364 assert isinstance(target, str)
365 self.add_target(target)
366 self._targets_type = C.GROUP
367
368 @property
369 def new_message_target(self):
370 if self._targets_type == C.ALL:
371 return C.PUBLIC
372 elif self._targets_type == C.GROUP:
373 return C.GROUP
374 else:
375 raise ValueError("Unkown targets type")
376
377 def __str__(self):
378 return "Blog Widget [target: {}, profile: {}]".format(
379 ", ".join(self.targets), self.profile
380 )
381
382 def _get_results_cb(self, data, rt_session):
383 remaining, results = data
384 log.debug(
385 "Got {got_len} results, {rem_len} remaining".format(
386 got_len=len(results), rem_len=remaining
387 )
388 )
389 for result in results:
390 service, node, failure, items_data, metadata = result
391 for item_data in items_data:
392 item_data[0] = data_format.deserialise(item_data[0])
393 for item_metadata in item_data[1]:
394 item_metadata[3] = [data_format.deserialise(i) for i in item_metadata[3]]
395 if not failure:
396 self._add_mb_items_with_comments((items_data, metadata),
397 service=jid.JID(service))
398
399 self.update()
400 if remaining:
401 self._get_results(rt_session)
402
403 def _get_results_eb(self, failure):
404 log.warning("microblog get_from_many error: {}".format(failure))
405
406 def _get_results(self, rt_session):
407 """Manage results from mb_get_from_many RT Session
408
409 @param rt_session(str): session id as returned by mb_get_from_many
410 """
411 self.host.bridge.mb_get_from_many_with_comments_rt_result(
412 rt_session,
413 profile=self.profile,
414 callback=lambda data: self._get_results_cb(data, rt_session),
415 errback=self._get_results_eb,
416 )
417
418 def get_all(self):
419 """Get all (micro)blogs from self.targets"""
420
421 def got_session(rt_session):
422 self._get_results(rt_session)
423
424 if self._targets_type in (C.ALL, C.GROUP):
425 targets = tuple(self.targets) if self._targets_type is C.GROUP else ()
426 self.host.bridge.mb_get_from_many_with_comments(
427 self._targets_type,
428 targets,
429 10,
430 10,
431 {},
432 {"subscribe": C.BOOL_TRUE},
433 profile=self.profile,
434 callback=got_session,
435 )
436 own_pep = self.host.whoami.bare
437 self.host.bridge.mb_get_from_many_with_comments(
438 C.JID,
439 (str(own_pep),),
440 10,
441 10,
442 {},
443 {},
444 profile=self.profile,
445 callback=got_session,
446 )
447 else:
448 raise NotImplementedError(
449 "{} target type is not managed".format(self._targets_type)
450 )
451
452 def is_jid_accepted(self, jid_):
453 """Tell if a jid is actepted and must be shown in this panel
454
455 @param jid_(jid.JID): jid to check
456 @return: True if the jid is accepted
457 """
458 if self._targets_type == C.ALL:
459 return True
460 assert self._targets_type is C.GROUP # we don't manage other types for now
461 for group in self.targets:
462 if self.host.contact_lists[self.profile].is_entity_in_group(jid_, group):
463 return True
464 return False
465
466 def add_entry_if_accepted(self, service, node, mb_data, groups, profile):
467 """add entry to this panel if it's acceptable
468
469 This method check if the entry is new or an update,
470 if it below to a know node, or if it acceptable anyway
471 @param service(jid.JID): jid of the emitting pubsub service
472 @param node(unicode): node identifier
473 @param mb_data: microblog data
474 @param groups(list[unicode], None): groups which can receive this entry
475 None to accept everything
476 @param profile: %(doc_profile)s
477 """
478 try:
479 entry = self.id2entries[mb_data["id"]]
480 except KeyError:
481 # The entry is new
482 try:
483 parent_entries = self.node2entries[(service, node)]
484 except:
485 # The node is unknown,
486 # we need to check that we can accept the entry
487 if (
488 self.is_jid_accepted(service)
489 or (
490 groups is None
491 and service == self.host.profiles[self.profile].whoami.bare
492 )
493 or (groups and groups.intersection(self.targets))
494 ):
495 self.add_entry(mb_data, service=service, node=node)
496 else:
497 # the entry is a comment in a known node
498 for parent_entry in parent_entries:
499 parent_entry.add_entry(mb_data, service=service, node=node)
500 else:
501 # The entry exist, it's an update
502 entry.reset(mb_data)
503 entry.refresh()
504
505 def delete_entry_if_present(self, service, node, item_id, profile):
506 """Delete and entry if present in this QuickBlog
507
508 @param sender(jid.JID): jid of the entry sender
509 @param mb_data: microblog data
510 @param service(jid.JID): sending service
511 @param node(unicode): hosting node
512 """
513 try:
514 entry = self.id2entries[item_id]
515 except KeyError:
516 pass
517 else:
518 entry.delete()
519
520
521 def register_class(type_, cls):
522 global ENTRY_CLS, COMMENTS_CLS
523 if type_ == "ENTRY":
524 ENTRY_CLS = cls
525 elif type == "COMMENT":
526 COMMENTS_CLS = cls
527 else:
528 raise ValueError("type_ should be ENTRY or COMMENT")
529 if COMMENTS_CLS is None:
530 COMMENTS_CLS = ENTRY_CLS