Fetching notifications works again, fixes #31
[diaspy.git] / diaspy / streams.py
CommitLineData
505fc964
MM
1"""Docstrings for this module are taken from:
2https://gist.github.com/MrZYX/01c93096c30dc44caf71
3
4Documentation for D* JSON API taken from:
5http://pad.spored.de/ro/r.qWmvhSZg7rk4OQam
6"""
1232dac5 7
f605e88d 8
4b1645dc
MM
9import json
10import time
11from diaspy.models import Post, Aspect
12from diaspy import errors
13
14
7a818fdb
MM
15class Generic():
16 """Object representing generic stream.
1232dac5 17 """
27a28aaf 18 _location = 'stream.json'
27a28aaf 19
b2905ea6 20 def __init__(self, connection, location='', fetch=True):
1232dac5
MM
21 """
22 :param connection: Connection() object
505fc964 23 :type connection: diaspy.connection.Connection
27a28aaf 24 :param location: location of json (optional)
505fc964 25 :type location: str
b2905ea6
MM
26 :param fetch: will call .fill() if true
27 :type fetch: bool
1232dac5
MM
28 """
29 self._connection = connection
505fc964 30 if location: self._location = location
63f1d9f1
MM
31 self._stream = []
32 # since epoch
33 self.max_time = int(time.mktime(time.gmtime()))
b2905ea6 34 if fetch: self.fill()
1232dac5
MM
35
36 def __contains__(self, post):
37 """Returns True if stream contains given post.
38 """
1232dac5
MM
39 return post in self._stream
40
41 def __iter__(self):
42 """Provides iterable interface for stream.
43 """
44 return iter(self._stream)
45
46 def __getitem__(self, n):
47 """Returns n-th item in Stream.
48 """
49 return self._stream[n]
50
51 def __len__(self):
52 """Returns length of the Stream.
53 """
54 return len(self._stream)
55
c84dcff8 56 def _obtain(self, max_time=0, suppress=True):
1232dac5 57 """Obtains stream from pod.
c84dcff8
MM
58
59 suppress:bool - suppress post-fetching errors (e.g. 404)
1232dac5 60 """
ed366d44 61 params = {}
dde4ddb7
MM
62 if max_time:
63 params['max_time'] = max_time
fe783229 64 params['_'] = int(time.time() * 1000)
39af9756 65 request = self._connection.get(self._location, params=params)
1232dac5 66 if request.status_code != 200:
4b1645dc 67 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
c84dcff8
MM
68 posts = []
69 for post in request.json():
70 try:
71 posts.append(Post(self._connection, guid=post['guid']))
72 except errors.PostError:
73 if not suppress:
74 raise
75 return posts
1232dac5 76
ed366d44
MM
77 def _expand(self, new_stream):
78 """Appends older posts to stream.
79 """
dde4ddb7 80 ids = [post.id for post in self._stream]
ed366d44
MM
81 stream = self._stream
82 for post in new_stream:
dde4ddb7 83 if post.id not in ids:
ed366d44 84 stream.append(post)
dde4ddb7 85 ids.append(post.id)
ed366d44
MM
86 self._stream = stream
87
88 def _update(self, new_stream):
89 """Updates stream with new posts.
90 """
78cc478a 91 ids = [post.id for post in self._stream]
ed366d44
MM
92
93 stream = self._stream
94 for i in range(len(new_stream)):
78cc478a 95 if new_stream[-i].id not in ids:
ed366d44 96 stream = [new_stream[-i]] + stream
9aa1c960 97 ids.append(new_stream[-i].id)
ed366d44
MM
98 self._stream = stream
99
1232dac5 100 def clear(self):
2cf8467c 101 """Set stream to empty.
1232dac5
MM
102 """
103 self._stream = []
104
505fc964
MM
105 def purge(self):
106 """Removes all unexistent posts from stream.
107 """
108 stream = []
109 for post in self._stream:
110 deleted = False
111 try:
39af9756 112 # error will tell us that the post has been deleted
78cc478a 113 post.update()
505fc964
MM
114 except Exception:
115 deleted = True
116 finally:
117 if not deleted: stream.append(post)
118 self._stream = stream
119
1232dac5 120 def update(self):
1aae28a3 121 """Updates stream with new posts.
1232dac5 122 """
3cf4514e 123 self._update(self._obtain())
1232dac5
MM
124
125 def fill(self):
126 """Fills the stream with posts.
1aae28a3
MM
127
128 **Notice:** this will create entirely new list of posts.
129 If you want to preseve posts already present in stream use update().
1232dac5
MM
130 """
131 self._stream = self._obtain()
132
fe783229 133 def more(self, max_time=0, backtime=84600):
1aae28a3 134 """Tries to download more (older posts) posts from Stream.
ed366d44 135
fe783229
MM
136 :param backtime: how many seconds substract each time (defaults to one day)
137 :type backtime: int
ed366d44
MM
138 :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own)
139 :type max_time: int
33b34938 140 """
fe783229 141 if not max_time: max_time = self.max_time - backtime
178faa46 142 self.max_time = max_time
ed366d44
MM
143 new_stream = self._obtain(max_time=max_time)
144 self._expand(new_stream)
33b34938 145
fe783229 146 def full(self, backtime=84600, retry=42, callback=None):
f61c14c1 147 """Fetches full stream - containing all posts.
fe783229
MM
148 WARNING: this is a **VERY** long running function.
149 Use callback parameter to access information about the stream during its
150 run.
151
152 Default backtime is one day. But sometimes user might not have any activity for longer
1aae28a3 153 period (in the beginning of my D* activity I was posting once a month or so).
fe783229
MM
154 The role of retry is to hadle such situations by trying to go further back in time.
155 If a post is found the counter is restored.
156
1aae28a3
MM
157 Default retry is 42. If you don't know why go to the nearest library (or to the nearest
158 Piratebay mirror) and grab a copy of "A Hitchhiker's Guide to the Galaxy" and read the
159 book to find out. This will also increase your level of geekiness and you'll have a
160 great time reading the book.
161
fe783229
MM
162 :param backtime: how many seconds to substract each time
163 :type backtime: int
164 :param retry: how many times the functin should look deeper than your last post
165 :type retry: int
166 :param callback: callable taking diaspy.streams.Generic as an argument
f61c14c1
MM
167 :returns: integer, lenght of the stream
168 """
169 oldstream = self.copy()
170 self.more()
fe783229 171 while len(oldstream) < len(self):
f61c14c1 172 oldstream = self.copy()
fe783229
MM
173 if callback is not None: callback(self)
174 self.more(backtime=backtime)
175 if len(oldstream) < len(self): continue
176 # but if no posts were found start retrying...
177 print('retrying... {0}'.format(retry))
178 n = retry
179 while n > 0:
180 print('\t', n, self.max_time)
181 # try to get even more posts...
182 self.more(backtime=backtime)
183 print('\t', len(oldstream), len(self))
184 # check if it was a success...
185 if len(oldstream) < len(self):
186 # and if so restore normal order of execution by
187 # going one loop higher
188 break
189 oldstream = self.copy()
b2905ea6
MM
190 # if it was not a success substract one backtime, keep calm and
191 # try going further back in time...
fe783229 192 n -= 1
b2905ea6
MM
193 # check the comment below
194 # no commented code should be present in good software
fe783229 195 #if len(oldstream) == len(self): break
f61c14c1
MM
196 return len(self)
197
dde4ddb7
MM
198 def copy(self):
199 """Returns copy (list of posts) of current stream.
200 """
201 return [p for p in self._stream]
202
6c692631 203 def json(self, comments=False, **kwargs):
f61c14c1
MM
204 """Returns JSON encoded string containing stream's data.
205
206 :param comments: to include comments or not to include 'em, that is the question this param holds answer to
207 :type comments: bool
208 """
209 stream = [post for post in self._stream]
210 if comments:
211 for i, post in enumerate(stream):
212 post._fetchcomments()
213 comments = [c.data for c in post.comments]
214 post['interactions']['comments'] = comments
215 stream[i] = post
6c692631
MM
216 stream = [post._data for post in stream]
217 return json.dumps(stream, **kwargs)
f61c14c1 218
1232dac5 219
beaa09fb
MM
220class Outer(Generic):
221 """Object used by diaspy.models.User to represent
222 stream of other user.
223 """
e4c9633a
MM
224 def __init__(self, connection, guid, fetch=True):
225 location = 'people/{}/stream.json'.format(guid)
226 super().__init__(connection, location, fetch)
227
dde4ddb7
MM
228 def _obtain(self, max_time=0):
229 """Obtains stream from pod.
beaa09fb 230 """
dde4ddb7
MM
231 params = {}
232 if max_time: params['max_time'] = max_time
233 request = self._connection.get(self._location, params=params)
beaa09fb 234 if request.status_code != 200:
dde4ddb7 235 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
78cc478a 236 return [Post(self._connection, post['id']) for post in request.json()]
beaa09fb
MM
237
238
1232dac5 239class Stream(Generic):
f605e88d
MM
240 """The main stream containing the combined posts of the
241 followed users and tags and the community spotlights posts
505fc964 242 if the user enabled those.
1232dac5 243 """
27a28aaf 244 location = 'stream.json'
505fc964 245
952a429e 246 def post(self, text='', aspect_ids='public', photos=None, photo='', provider_display_name=''):
a98c6792
MM
247 """This function sends a post to an aspect.
248 If both `photo` and `photos` are specified `photos` takes precedence.
1232dac5
MM
249
250 :param text: Text to post.
251 :type text: str
252 :param aspect_ids: Aspect ids to send post to.
253 :type aspect_ids: str
a98c6792
MM
254 :param photo: filename of photo to post
255 :type photo: str
256 :param photos: id of photo to post (obtained from _photoupload())
257 :type photos: int
952a429e
SB
258 :param provider_display_name: name of provider displayed under the post
259 :type provider_display_name: str
1232dac5
MM
260
261 :returns: diaspy.models.Post -- the Post which has been created
262 """
263 data = {}
264 data['aspect_ids'] = aspect_ids
952a429e 265 data['status_message'] = {'text': text, 'provider_display_name': provider_display_name}
a98c6792 266 if photo: data['photos'] = self._photoupload(photo)
1232dac5 267 if photos: data['photos'] = photos
a98c6792 268
1232dac5
MM
269 request = self._connection.post('status_messages',
270 data=json.dumps(data),
271 headers={'content-type': 'application/json',
272 'accept': 'application/json',
78cc478a 273 'x-csrf-token': repr(self._connection)})
1232dac5 274 if request.status_code != 201:
a98c6792 275 raise Exception('{0}: Post could not be posted.'.format(request.status_code))
78cc478a 276 post = Post(self._connection, request.json()['id'])
1232dac5
MM
277 return post
278
39af9756 279 def _photoupload(self, filename, aspects=[]):
66c3bb76 280 """Uploads picture to the pod.
1232dac5 281
66c3bb76 282 :param filename: path to picture file
1232dac5 283 :type filename: str
39af9756
MM
284 :param aspect_ids: list of ids of aspects to which you want to upload this photo
285 :type aspect_ids: list of integers
66c3bb76
MM
286
287 :returns: id of the photo being uploaded
1232dac5 288 """
38fabb63
MM
289 data = open(filename, 'rb')
290 image = data.read()
291 data.close()
292
1232dac5
MM
293 params = {}
294 params['photo[pending]'] = 'true'
295 params['set_profile_image'] = ''
296 params['qqfile'] = filename
39af9756 297 if not aspects: aspects = self._connection.getUserData()['aspects']
1232dac5 298 for i, aspect in enumerate(aspects):
38fabb63 299 params['photo[aspect_ids][{0}]'.format(i)] = aspect['id']
1232dac5
MM
300
301 headers = {'content-type': 'application/octet-stream',
78cc478a 302 'x-csrf-token': repr(self._connection),
1232dac5 303 'x-file-name': filename}
38fabb63
MM
304
305 request = self._connection.post('photos', data=image, params=params, headers=headers)
306 if request.status_code != 200:
2d4f6eeb 307 raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code))
66c3bb76
MM
308 return request.json()['data']['photo']['id']
309
1232dac5 310
278febce 311class Activity(Stream):
1232dac5
MM
312 """Stream representing user's activity.
313 """
27a28aaf 314 _location = 'activity.json'
505fc964
MM
315
316 def _delid(self, id):
317 """Deletes post with given id.
318 """
319 post = None
320 for p in self._stream:
321 if p['id'] == id:
322 post = p
323 break
324 if post is not None: post.delete()
325
326 def delete(self, post):
327 """Deletes post from users activity.
328 `post` can be either post id or Post()
329 object which will be identified and deleted.
39af9756 330 After deleting post the stream will be purged.
505fc964
MM
331
332 :param post: post identifier
333 :type post: str, diaspy.models.Post
334 """
335 if type(post) == str: self._delid(post)
336 elif type(post) == Post: post.delete()
63f1d9f1 337 else: raise TypeError('this method accepts str or Post types: {0} given')
39af9756 338 self.purge()
505fc964
MM
339
340
341class Aspects(Generic):
f605e88d
MM
342 """This stream contains the posts filtered by
343 the specified aspect IDs. You can choose the aspect IDs with
344 the parameter `aspect_ids` which value should be
345 a comma seperated list of aspect IDs.
346 If the parameter is ommitted all aspects are assumed.
505fc964
MM
347 An example call would be `aspects.json?aspect_ids=23,5,42`
348 """
27a28aaf 349 _location = 'aspects.json'
27a28aaf 350
278febce 351 def getAspectID(self, aspect_name):
63cc182d
MM
352 """Returns id of an aspect of given name.
353 Returns -1 if aspect is not found.
354
278febce
MM
355 :param aspect_name: aspect name (must be spelled exactly as when created)
356 :type aspect_name: str
63cc182d
MM
357 :returns: int
358 """
359 id = -1
39af9756 360 aspects = self._connection.getUserData()['aspects']
278febce
MM
361 for aspect in aspects:
362 if aspect['name'] == aspect_name: id = aspect['id']
63cc182d
MM
363 return id
364
39af9756
MM
365 def filter(self, ids):
366 """Filters posts by given aspect ids.
367
368 :parameter ids: list of apsect ids
369 :type ids: list of integers
370 """
371 self._location = 'aspects.json' + '?{0}'.format(','.join(ids))
27a28aaf
MM
372 self.fill()
373
505fc964
MM
374 def add(self, aspect_name, visible=0):
375 """This function adds a new aspect.
278febce 376 Status code 422 is accepted because it is returned by D* when
27a28aaf
MM
377 you try to add aspect already present on your aspect list.
378
b2905ea6
MM
379 :param aspect_name: name of aspect to create
380 :param visible: whether the contacts in this aspect are visible to each other or not
381
d589deff 382 :returns: Aspect() object of just created aspect
505fc964 383 """
b2905ea6 384 data = {'authenticity_token': repr(self._connection),
505fc964
MM
385 'aspect[name]': aspect_name,
386 'aspect[contacts_visible]': visible}
387
27a28aaf
MM
388 request = self._connection.post('aspects', data=data)
389 if request.status_code not in [200, 422]:
390 raise Exception('wrong status code: {0}'.format(request.status_code))
391
278febce 392 id = self.getAspectID(aspect_name)
d589deff 393 return Aspect(self._connection, id)
505fc964 394
73a9e0d3 395 def remove(self, id=-1, name=''):
fbb19900 396 """This method removes an aspect.
278febce
MM
397 You can give it either id or name of the aspect.
398 When both are specified, id takes precedence over name.
399
400 Status code 500 is accepted because although the D* will
27a28aaf
MM
401 go nuts it will remove the aspect anyway.
402
403 :param aspect_id: id fo aspect to remove
404 :type aspect_id: int
1467ec15
MM
405 :param name: name of aspect to remove
406 :type name: str
fbb19900 407 """
73a9e0d3 408 if id == -1 and name: id = self.getAspectID(name)
27f09973 409 data = {'_method': 'delete',
b2905ea6 410 'authenticity_token': repr(self._connection)}
73a9e0d3 411 request = self._connection.post('aspects/{0}'.format(id), data=data)
dd0a4d9f
MM
412 if request.status_code not in [200, 302, 500]:
413 raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code))
fbb19900 414
505fc964
MM
415
416class Commented(Generic):
f605e88d 417 """This stream contains all posts
505fc964
MM
418 the user has made a comment on.
419 """
27a28aaf 420 _location = 'commented.json'
505fc964
MM
421
422
423class Liked(Generic):
424 """This stream contains all posts the user liked.
425 """
27a28aaf 426 _location = 'liked.json'
505fc964
MM
427
428
429class Mentions(Generic):
f605e88d 430 """This stream contains all posts
505fc964
MM
431 the user is mentioned in.
432 """
27a28aaf 433 _location = 'mentions.json'
505fc964
MM
434
435
436class FollowedTags(Generic):
f605e88d 437 """This stream contains all posts
6c416e80 438 containing tags the user is following.
505fc964 439 """
27a28aaf 440 _location = 'followed_tags.json'
505fc964 441
39af9756
MM
442 def get(self):
443 """Returns list of followed tags.
444 """
445 return []
446
a7c098e3 447 def remove(self, tag_id):
fbb19900
MM
448 """Stop following a tag.
449
a7c098e3
MM
450 :param tag_id: tag id
451 :type tag_id: int
fbb19900 452 """
f605e88d 453 data = {'authenticity_token': self._connection.get_token()}
a7c098e3
MM
454 request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data)
455 if request.status_code != 404:
456 raise Exception('wrong status code: {0}'.format(request.status_code))
fbb19900
MM
457
458 def add(self, tag_name):
505fc964 459 """Follow new tag.
f605e88d 460 Error code 403 is accepted because pods respod with it when request
6c416e80 461 is sent to follow a tag that a user already follows.
505fc964
MM
462
463 :param tag_name: tag name
464 :type tag_name: str
6c416e80 465 :returns: int (response code)
505fc964 466 """
f605e88d 467 data = {'name': tag_name,
b2905ea6 468 'authenticity_token': repr(self._connection),
f605e88d
MM
469 }
470 headers = {'content-type': 'application/json',
b2905ea6 471 'x-csrf-token': repr(self._connection),
f605e88d
MM
472 'accept': 'application/json'
473 }
f0fa9fec
MK
474
475 request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers)
476
6c416e80 477 if request.status_code not in [201, 403]:
505fc964 478 raise Exception('wrong error code: {0}'.format(request.status_code))
6c416e80 479 return request.status_code
2ec93347 480
7a818fdb
MM
481
482class Tag(Generic):
483 """This stream contains all posts containing a tag.
484 """
39af9756 485 def __init__(self, connection, tag, fetch=True):
7a818fdb
MM
486 """
487 :param connection: Connection() object
488 :type connection: diaspy.connection.Connection
489 :param tag: tag name
490 :type tag: str
491 """
492 self._connection = connection
6cd1bae0 493 self._location = 'tags/{0}.json'.format(tag)
39af9756 494 if fetch: self.fill()