comparison sat_frontends/quick_frontend/quick_blog.py @ 2562:26edcf3a30eb

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