Mercurial > libervia-backend
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 | 0d7bb4df2343 |
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 |