Mercurial > libervia-backend
comparison frontends/src/quick_frontend/quick_blog.py @ 1461:9fce331ba0fd
quick_frontend (constants, quick_app, quick_contact_list): blogging refactoring (not finished):
- adaptation to backend modifications
- moved common blogging parts from Libervia to quick frontend
- QuickApp use a MB_HANDLE class variable to indicated if blogging is managed by the frontend (and avoid waste of resources if not)
- comments are now managed inside parent entry, as a result a tree of comments is now possible
- Entry.level indicate where in the tree we are (-1 mean parent QuickBlog, 0 mean main item, more means comment)
- items + comments are requested in 1 shot, and RTDeferred are used to avoid blocking while waiting for them
- in QuickBlog, id2entries allow to get Entry from it's item id, and node2entries allow to get Entry(ies) hosting a comments node
- QuickBlog.new_message_target tell where a new message will be sent by default
author | Goffi <goffi@goffi.org> |
---|---|
date | Sun, 16 Aug 2015 01:00:54 +0200 |
parents | |
children | 955221487a3e |
comparison
equal
deleted
inserted
replaced
1460:c7fd121a6180 | 1461:9fce331ba0fd |
---|---|
1 #!/usr/bin/python | |
2 # -*- coding: utf-8 -*- | |
3 | |
4 # helper class for making a SAT frontend | |
5 # Copyright (C) 2011, 2012, 2013, 2014, 2015 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 sat.core.log import getLogger | |
22 log = getLogger(__name__) | |
23 | |
24 | |
25 from sat_frontends.quick_frontend.constants import Const as C | |
26 from sat_frontends.quick_frontend import quick_widgets | |
27 from sat_frontends.tools import jid | |
28 | |
29 try: | |
30 # FIXME: to be removed when an acceptable solution is here | |
31 unicode('') # XXX: unicode doesn't exist in pyjamas | |
32 except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options | |
33 unicode = str | |
34 | |
35 ENTRY_CLS = None | |
36 COMMENTS_CLS = None | |
37 | |
38 | |
39 class Item(object): | |
40 """Manage all (meta)data of an item""" | |
41 | |
42 def __init__(self, data): | |
43 """ | |
44 @param data(dict, None): microblog data as return by bridge methods | |
45 if data is None, set a default values | |
46 """ | |
47 self.id = data['id'] | |
48 self.title = data.get('title') | |
49 self.title_rich = None | |
50 self.title_xhtml = data.get('title_xhtml') | |
51 self.content = data.get('content') | |
52 self.content_rich = None | |
53 self.content_xhtml = data.get('content_xhtml') | |
54 self.author = data['author'] | |
55 try: | |
56 author_jid = data['author_jid'] | |
57 self.author_jid = jid.JID(author_jid) if author_jid else None | |
58 except KeyError: | |
59 self.author_jid = None | |
60 | |
61 try: | |
62 self.author_verified = C.bool(data['author_jid_verified']) | |
63 except KeyError: | |
64 self.author_verified = False | |
65 | |
66 try: | |
67 self.updated = float(data['updated']) # XXX: int doesn't work here (pyjamas bug) | |
68 except KeyError: | |
69 self.updated = None | |
70 | |
71 try: | |
72 self.published = float(data['published']) # XXX: int doesn't work here (pyjamas bug) | |
73 except KeyError: | |
74 self.published = None | |
75 | |
76 self.comments = data.get('comments') | |
77 try: | |
78 self.comments_service = jid.JID(data['comments_service']) | |
79 except KeyError: | |
80 self.comments_service = None | |
81 self.comments_node = data.get('comments_node') | |
82 | |
83 # def loadComments(self): | |
84 # """Load all the comments""" | |
85 # index = str(main_entry.comments_count - main_entry.hidden_count) | |
86 # rsm = {'max': str(main_entry.hidden_count), 'index': index} | |
87 # self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm) | |
88 | |
89 | |
90 class EntriesManager(object): | |
91 """Class which manages list of (micro)blog entries""" | |
92 | |
93 def __init__(self, manager): | |
94 """ | |
95 @param manager (EntriesManager, None): parent EntriesManager | |
96 must be None for QuickBlog (and only for QuickBlog) | |
97 """ | |
98 self.manager = manager | |
99 if manager is None: | |
100 self.blog = self | |
101 else: | |
102 self.blog = manager.blog | |
103 self.entries = [] | |
104 self._first_entry = None | |
105 | |
106 @property | |
107 def level(self): | |
108 """indicate how deep is this entry in the tree | |
109 | |
110 if level == -1, we have a QuickBlog | |
111 if level == 0, we have a main item | |
112 else we have a comment | |
113 """ | |
114 level = -1 | |
115 manager = self.manager | |
116 while manager is not None: | |
117 level += 1 | |
118 manager = manager.manager | |
119 return level | |
120 | |
121 def _addMBItems(self, items_tuple, service=None, node=None): | |
122 """Add Microblog items to this panel | |
123 update is NOT called after addition | |
124 | |
125 @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGetLast | |
126 """ | |
127 items, metadata = items_tuple | |
128 for item in items: | |
129 self.addEntry(item, service=service, node=node, with_update=False) | |
130 | |
131 def _addMBItemsWithComments(self, items_tuple, service=None, node=None): | |
132 """Add Microblog items to this panel | |
133 update is NOT called after addition | |
134 | |
135 @param items_tuple(tuple): (items_data,items_metadata) tuple as returned by mbGetLast | |
136 """ | |
137 items, metadata = items_tuple | |
138 for item, comments in items: | |
139 self.addEntry(item, comments, service=service, node=node, with_update=False) | |
140 | |
141 def addEntry(self, item=None, comments=None, service=None, node=None, with_update=True, editable=False, first=False): | |
142 """Add a microblog entry | |
143 | |
144 @param editable (bool): True if the entry can be modified | |
145 @param item (dict, None): blog item data, or None for an empty entry | |
146 @param comments (list, None): list of comments data if available | |
147 @param service (jid.JID, None): service where the entry is coming from | |
148 @param service (unicode, None): node hosting the entry | |
149 @param with_update (bool): if True, udpate is called with the new entry | |
150 @param first(bool): if True, will be the first entry regardless of sorting | |
151 """ | |
152 new_entry = ENTRY_CLS(self, item, comments, service=service, node=node) | |
153 new_entry.setEditable(editable) | |
154 if first: | |
155 self._first_entry = new_entry | |
156 else: | |
157 self.entries.append(new_entry) | |
158 if with_update: | |
159 self.update() | |
160 return new_entry | |
161 | |
162 def update(self, entry=None): | |
163 """Update the display with entries | |
164 | |
165 @param entry (Entry, None): if not None, must be the new entry. | |
166 If None, all the items will be checked to update the display | |
167 """ | |
168 # update is separated from addEntry to allow adding | |
169 # several entries at once, and updating at the end | |
170 raise NotImplementedError | |
171 | |
172 | |
173 class Entry(EntriesManager): | |
174 """Graphical representation of an Item | |
175 This class must be overriden by frontends""" | |
176 | |
177 def __init__(self, manager, item_data=None, comments_data=None, service=None, node=None): | |
178 """ | |
179 @param blog(QuickBlog): the parent QuickBlog | |
180 @param manager(EntriesManager): the parent EntriesManager | |
181 @param item_data(dict, None): dict containing the blog item data, or None for an empty entry | |
182 @param comments_data(list, None): list of comments data | |
183 """ | |
184 assert manager is not None | |
185 EntriesManager.__init__(self, manager) | |
186 self.service = service | |
187 self.node = node | |
188 self.editable = False | |
189 self.reset(item_data) | |
190 self.blog.id2entries[self.item.id] = self | |
191 if self.item.comments: | |
192 node_tuple = (self.item.comments_service, self.item.comments_node) | |
193 self.blog.node2entries.setdefault(node_tuple,[]).append(self) | |
194 | |
195 def reset(self, item_data): | |
196 """Reset the entry with given data | |
197 | |
198 used during init (it's a set and not a reset then) | |
199 or later (e.g. message sent, or cancellation of an edition | |
200 @param idem_data(dict): data as in __init__ | |
201 """ | |
202 if item_data is None: | |
203 self.new = True | |
204 item_data = {'id': None, | |
205 # TODO: find a best author value | |
206 'author': self.blog.host.whoami.node | |
207 } | |
208 else: | |
209 self.new = False | |
210 self.item = Item(item_data) | |
211 self.author_jid = self.blog.host.whoami.bare if self.new else self.item.author_jid | |
212 if self.author_jid is None and self.service and self.service.node: | |
213 self.author_jid = self.service | |
214 self.mode = C.ENTRY_MODE_TEXT if self.item.content_xhtml is None else C.ENTRY_MODE_XHTML | |
215 | |
216 def refresh(semf): | |
217 """Refresh the display when data have been modified""" | |
218 pass | |
219 | |
220 def setEditable(self, editable=True): | |
221 """tell if the entry can be edited or not | |
222 | |
223 @param editable(bool): True if the entry can be edited | |
224 """ | |
225 #XXX: we don't use @property as property setter doesn't play well with pyjamas | |
226 raise NotImplementedError | |
227 | |
228 def addComments(self, comments_data): | |
229 """Add comments to this entry by calling addEntry repeatidly | |
230 | |
231 @param comments_data(tuple): data as returned by mbGetFromMany*RTResults | |
232 """ | |
233 # TODO: manage seperator between comments of coming from different services/nodes | |
234 for data in comments_data: | |
235 service, node, failure, comments, metadata = data | |
236 for comment in comments: | |
237 if not failure: | |
238 self.addEntry(comment, service=jid.JID(service), node=node) | |
239 else: | |
240 log.warning("getting comment failed: {}".format(failure)) | |
241 self.update() | |
242 | |
243 def send(self): | |
244 """Send entry according to parent QuickBlog configuration and current level""" | |
245 | |
246 # keys other to keep other than content* and title* | |
247 keys_to_keep = ('id', 'comments', 'author', 'author_jid', 'published') | |
248 | |
249 mb_data = {} | |
250 for key in keys_to_keep: | |
251 value = getattr(self.item, key) | |
252 if value is not None: | |
253 mb_data[key] = unicode(value) | |
254 | |
255 for prefix in ('content', 'title'): | |
256 for suffix in ('', '_rich', '_xhtml'): | |
257 name = '{}{}'.format(prefix, suffix) | |
258 value = getattr(self.item, name) | |
259 if value is not None: | |
260 mb_data[name] = value | |
261 | |
262 if self.level == 0: | |
263 if self.blog.new_message_target == C.PUBLIC: | |
264 if self.new: | |
265 mb_data["allow_comments"] = C.BOOL_TRUE | |
266 else: | |
267 raise NotImplementedError | |
268 | |
269 self.blog.host.bridge.mbSend( | |
270 unicode(self.service or ''), | |
271 self.node or '', | |
272 mb_data, | |
273 profile=self.blog.profile) | |
274 | |
275 def delete(self): | |
276 """Remove this Entry from parent manager | |
277 | |
278 This doesn't delete any entry in PubSub, just locally | |
279 all children entries will be recursively removed too | |
280 """ | |
281 # XXX: named delete and not remove to avoid conflict with pyjamas | |
282 log.debug(u"deleting entry {}".format('EDIT ENTRY' if self.new else self.item.id)) | |
283 for child in self.entries: | |
284 child.delete() | |
285 self.manager.entries.remove(self) | |
286 if not self.new: | |
287 # we must remove references to self | |
288 # in QuickBlog's dictionary | |
289 del self.blog.id2entries[self.item.id] | |
290 if self.item.comments: | |
291 comments_tuple = (self.item.comments_service, | |
292 self.item.comments_node) | |
293 other_entries = self.blog.node2entries[comments_tuple].remove(self) | |
294 if not other_entries: | |
295 del self.blog.node2entries[comments_tuple] | |
296 | |
297 def retract(self): | |
298 """Retract this item from microblog node | |
299 | |
300 if there is a comments node, it will be purged too | |
301 """ | |
302 # TODO: manage several comments nodes case. | |
303 if self.item.comments: | |
304 self.blog.host.bridge.psDeleteNode(unicode(self.item.comments_service) or "", self.item.comments_node, profile=self.blog.profile) | |
305 self.blog.host.bridge.mbRetract(unicode(self.service or ""), self.node or "", self.item.id, profile=self.blog.profile) | |
306 | |
307 | |
308 class QuickBlog(EntriesManager, quick_widgets.QuickWidget): | |
309 | |
310 def __init__(self, host, targets, profiles=None): | |
311 """Panel used to show microblog | |
312 | |
313 @param targets (tuple(unicode)): contact groups displayed in this panel. | |
314 If empty, show all microblogs from all contacts. targets is also used | |
315 to know where to send new messages. | |
316 """ | |
317 EntriesManager.__init__(self, None) | |
318 self.id2entries = {} # used to find an entry with it's item id | |
319 # must be kept up-to-date by Entry | |
320 self.node2entries = {} # same as above, values are lists in case of | |
321 # two entries link to the same comments node | |
322 if not targets: | |
323 targets = () # XXX: we use empty tuple instead of None to workaround a pyjamas bug | |
324 quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE) | |
325 self._targets_type = C.ALL | |
326 else: | |
327 assert isinstance(targets[0], basestring) | |
328 quick_widgets.QuickWidget.__init__(self, host, targets[0], C.PROF_KEY_NONE) | |
329 for target in targets[1:]: | |
330 assert isinstance(target, basestring) | |
331 self.addTarget(target) | |
332 self._targets_type = C.GROUP | |
333 | |
334 @property | |
335 def new_message_target(self): | |
336 if self._targets_type == C.ALL: | |
337 return C.PUBLIC | |
338 elif self._targets_type == C.GROUP: | |
339 return self.targets | |
340 else: | |
341 raise ValueError("Unkown targets type") | |
342 | |
343 def __str__(self): | |
344 return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.targets), self.profile) | |
345 | |
346 def _getResultsCb(self, data, rt_session): | |
347 remaining, results = data | |
348 log.debug("Got {got_len} results, {rem_len} remaining".format(got_len=len(results), rem_len=remaining)) | |
349 for result in results: | |
350 service, node, failure, items, metadata = result | |
351 if not failure: | |
352 self._addMBItemsWithComments((items, metadata), service=jid.JID(service)) | |
353 | |
354 self.update() | |
355 if remaining: | |
356 self._getResults(rt_session) | |
357 | |
358 def _getResultsEb(self, failure): | |
359 log.warning("microblog getFromMany error: {}".format(failure)) | |
360 | |
361 def _getResults(self, rt_session): | |
362 """Manage results from mbGetFromMany RT Session | |
363 | |
364 @param rt_session(str): session id as returned by mbGetFromMany | |
365 """ | |
366 self.host.bridge.mbGetFromManyWithCommentsRTResult(rt_session, profile=self.profile, | |
367 callback=lambda data:self._getResultsCb(data, rt_session), | |
368 errback=self._getResultsEb) | |
369 | |
370 def getAll(self): | |
371 """Get all (micro)blogs from self.targets""" | |
372 def gotSession(rt_session): | |
373 self._getResults(rt_session) | |
374 | |
375 if self._targets_type == C.ALL: | |
376 self.host.bridge.mbGetFromManyWithComments(C.ALL, (), 10, 10, {}, {"subscribe":C.BOOL_TRUE}, profile=self.profile, callback=gotSession) | |
377 own_pep = self.host.whoami.bare | |
378 self.host.bridge.mbGetFromManyWithComments(C.JID, (unicode(own_pep),), 10, 10, {}, {}, profile=self.profile, callback=gotSession) | |
379 | |
380 def isJidAccepted(self, jid_): | |
381 """Tell if a jid is actepted and must be shown in this panel | |
382 | |
383 @param jid_(jid.JID): jid to check | |
384 @return: True if the jid is accepted | |
385 """ | |
386 if self._targets_type == C.ALL: | |
387 return True | |
388 assert self._targets_type is C.GROUP # we don't manage other types for now | |
389 for group in self.targets: | |
390 if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group): | |
391 return True | |
392 return False | |
393 | |
394 def addEntryIfAccepted(self, service, node, mb_data, groups, profile): | |
395 """add entry to this panel if it's acceptable | |
396 | |
397 This method check if the entry is new or an update, | |
398 if it below to a know node, or if it acceptable anyway | |
399 @param service(jid.JID): jid of the emitting pubsub service | |
400 @param node(unicode): node identifier | |
401 @param mb_data: microblog data | |
402 @param groups(list[unicode], None): groups which can receive this entry | |
403 None to accept everything | |
404 @param profile: %(doc_profile)s | |
405 """ | |
406 try: | |
407 entry = self.id2entries[mb_data['id']] | |
408 except KeyError: | |
409 # The entry is new | |
410 try: | |
411 parent_entries = self.node2entries[(service, node)] | |
412 except: | |
413 # The node is unknown, | |
414 # we need to check that we can accept the entry | |
415 if (self.isJidAccepted(service) | |
416 or (groups is None and service == self.host.profiles[self.profile].whoami.bare) | |
417 or (groups and groups.intersection(self.targets))): | |
418 self.addEntry(mb_data, service=service, node=node) | |
419 else: | |
420 # the entry is a comment in a known node | |
421 for parent_entry in parent_entries: | |
422 parent_entry.addEntry(mb_data, service=service, node=node) | |
423 else: | |
424 # The entry exist, it's an update | |
425 entry.reset(mb_data) | |
426 entry.refresh() | |
427 | |
428 def deleteEntryIfPresent(self, service, node, item_id, profile): | |
429 """Delete and entry if present in this QuickBlog | |
430 | |
431 @param sender(jid.JID): jid of the entry sender | |
432 @param mb_data: microblog data | |
433 @param service(jid.JID): sending service | |
434 @param node(unicode): hosting node | |
435 """ | |
436 try: | |
437 entry = self.id2entries[item_id] | |
438 except KeyError: | |
439 pass | |
440 else: | |
441 entry.delete() | |
442 | |
443 | |
444 def registerClass(type_, cls): | |
445 global ENTRY_CLS, COMMENTS_CLS | |
446 if type_ == "ENTRY": | |
447 ENTRY_CLS = cls | |
448 elif type == "COMMENT": | |
449 COMMENTS_CLS = cls | |
450 else: | |
451 raise ValueError("type_ should be ENTRY or COMMENT") | |
452 if COMMENTS_CLS is None: | |
453 COMMENTS_CLS = ENTRY_CLS |