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