New stuff for version 0.4.1 detailed decription in Changelog
[diaspy.git] / diaspy / streams.py
1 """Docstrings for this module are taken from:
2 https://gist.github.com/MrZYX/01c93096c30dc44caf71
3
4 Documentation for D* JSON API taken from:
5 http://pad.spored.de/ro/r.qWmvhSZg7rk4OQam
6 """
7
8
9 import json
10 import time
11 from diaspy.models import Post, Aspect
12 from diaspy import errors
13
14
15 class Generic():
16 """Object representing generic stream.
17 """
18 _location = 'stream.json'
19
20 def __init__(self, connection, location=''):
21 """
22 :param connection: Connection() object
23 :type connection: diaspy.connection.Connection
24 :param location: location of json (optional)
25 :type location: str
26 """
27 self._connection = connection
28 if location: self._location = location
29 self._stream = []
30 # since epoch
31 self.max_time = int(time.mktime(time.gmtime()))
32 self.fill()
33
34 def __contains__(self, post):
35 """Returns True if stream contains given post.
36 """
37 return post in self._stream
38
39 def __iter__(self):
40 """Provides iterable interface for stream.
41 """
42 return iter(self._stream)
43
44 def __getitem__(self, n):
45 """Returns n-th item in Stream.
46 """
47 return self._stream[n]
48
49 def __len__(self):
50 """Returns length of the Stream.
51 """
52 return len(self._stream)
53
54 def _obtain(self, max_time=0):
55 """Obtains stream from pod.
56 """
57 params = {}
58 if max_time:
59 params['max_time'] = max_time
60 params['_'] = self.max_time
61 request = self._connection.get(self._location, params=params)
62 if request.status_code != 200:
63 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
64 return [Post(self._connection, post['id']) for post in request.json()]
65
66 def _expand(self, new_stream):
67 """Appends older posts to stream.
68 """
69 ids = [post.id for post in self._stream]
70 stream = self._stream
71 for post in new_stream:
72 if post.id not in ids:
73 stream.append(post)
74 ids.append(post.id)
75 self._stream = stream
76
77 def _update(self, new_stream):
78 """Updates stream with new posts.
79 """
80 ids = [post.id for post in self._stream]
81
82 stream = self._stream
83 for i in range(len(new_stream)):
84 if new_stream[-i].id not in ids:
85 stream = [new_stream[-i]] + stream
86 ids.append(new_stream[-i].id)
87 self._stream = stream
88
89 def clear(self):
90 """Removes all posts from stream.
91 """
92 self._stream = []
93
94 def purge(self):
95 """Removes all unexistent posts from stream.
96 """
97 stream = []
98 for post in self._stream:
99 deleted = False
100 try:
101 post.update()
102 stream.append(post)
103 except Exception:
104 deleted = True
105 finally:
106 if not deleted: stream.append(post)
107 self._stream = stream
108
109 def update(self):
110 """Updates stream.
111 """
112 self._update(self._obtain())
113
114 def fill(self):
115 """Fills the stream with posts.
116 """
117 self._stream = self._obtain()
118
119 def more(self, max_time=0):
120 """Tries to download more (older ones) Posts from Stream.
121
122 :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own)
123 :type max_time: int
124 """
125 if not max_time: max_time = self.max_time - 3000000
126 self.max_time = max_time
127 new_stream = self._obtain(max_time=max_time)
128 self._expand(new_stream)
129
130 def full(self):
131 """Fetches full stream - containing all posts.
132 WARNING: this can be a **VERY** time consuming function on slow connections of massive streams.
133
134 :returns: integer, lenght of the stream
135 """
136 oldstream = self.copy()
137 self.more()
138 while len(oldstream) != len(self):
139 oldstream = self.copy()
140 self.more()
141 return len(self)
142
143 def copy(self):
144 """Returns copy (list of posts) of current stream.
145 """
146 return [p for p in self._stream]
147
148 def json(self, comments=False):
149 """Returns JSON encoded string containing stream's data.
150
151 :param comments: to include comments or not to include 'em, that is the question this param holds answer to
152 :type comments: bool
153 """
154 stream = [post for post in self._stream]
155 if comments:
156 for i, post in enumerate(stream):
157 post._fetchcomments()
158 comments = [c.data for c in post.comments]
159 post['interactions']['comments'] = comments
160 stream[i] = post
161 stream = [post.data for post in stream]
162 return json.dumps(stream)
163
164
165 class Outer(Generic):
166 """Object used by diaspy.models.User to represent
167 stream of other user.
168 """
169 def _obtain(self, max_time=0):
170 """Obtains stream from pod.
171 """
172 params = {}
173 if max_time: params['max_time'] = max_time
174 request = self._connection.get(self._location, params=params)
175 if request.status_code != 200:
176 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
177 return [Post(self._connection, post['id']) for post in request.json()]
178
179
180 class Stream(Generic):
181 """The main stream containing the combined posts of the
182 followed users and tags and the community spotlights posts
183 if the user enabled those.
184 """
185 location = 'stream.json'
186
187 def post(self, text='', aspect_ids='public', photos=None, photo=''):
188 """This function sends a post to an aspect.
189 If both `photo` and `photos` are specified `photos` takes precedence.
190
191 :param text: Text to post.
192 :type text: str
193 :param aspect_ids: Aspect ids to send post to.
194 :type aspect_ids: str
195 :param photo: filename of photo to post
196 :type photo: str
197 :param photos: id of photo to post (obtained from _photoupload())
198 :type photos: int
199
200 :returns: diaspy.models.Post -- the Post which has been created
201 """
202 data = {}
203 data['aspect_ids'] = aspect_ids
204 data['status_message'] = {'text': text}
205 if photo: data['photos'] = self._photoupload(photo)
206 if photos: data['photos'] = photos
207
208 request = self._connection.post('status_messages',
209 data=json.dumps(data),
210 headers={'content-type': 'application/json',
211 'accept': 'application/json',
212 'x-csrf-token': repr(self._connection)})
213 if request.status_code != 201:
214 raise Exception('{0}: Post could not be posted.'.format(request.status_code))
215
216 post = Post(self._connection, request.json()['id'])
217 return post
218
219 def _photoupload(self, filename):
220 """Uploads picture to the pod.
221
222 :param filename: path to picture file
223 :type filename: str
224
225 :returns: id of the photo being uploaded
226 """
227 data = open(filename, 'rb')
228 image = data.read()
229 data.close()
230
231 params = {}
232 params['photo[pending]'] = 'true'
233 params['set_profile_image'] = ''
234 params['qqfile'] = filename
235 aspects = self._connection.getUserInfo()['aspects']
236 for i, aspect in enumerate(aspects):
237 params['photo[aspect_ids][{0}]'.format(i)] = aspect['id']
238
239 headers = {'content-type': 'application/octet-stream',
240 'x-csrf-token': repr(self._connection),
241 'x-file-name': filename}
242
243 request = self._connection.post('photos', data=image, params=params, headers=headers)
244 if request.status_code != 200:
245 raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code))
246 return request.json()['data']['photo']['id']
247
248
249 class Activity(Stream):
250 """Stream representing user's activity.
251 """
252 _location = 'activity.json'
253
254 def _delid(self, id):
255 """Deletes post with given id.
256 """
257 post = None
258 for p in self._stream:
259 if p['id'] == id:
260 post = p
261 break
262 if post is not None: post.delete()
263
264 def delete(self, post):
265 """Deletes post from users activity.
266 `post` can be either post id or Post()
267 object which will be identified and deleted.
268 After deleting post the stream will be filled.
269
270 :param post: post identifier
271 :type post: str, diaspy.models.Post
272 """
273 if type(post) == str: self._delid(post)
274 elif type(post) == Post: post.delete()
275 else: raise TypeError('this method accepts str or Post types: {0} given')
276 self.fill()
277
278
279 class Aspects(Generic):
280 """This stream contains the posts filtered by
281 the specified aspect IDs. You can choose the aspect IDs with
282 the parameter `aspect_ids` which value should be
283 a comma seperated list of aspect IDs.
284 If the parameter is ommitted all aspects are assumed.
285 An example call would be `aspects.json?aspect_ids=23,5,42`
286 """
287 _location = 'aspects.json'
288
289 def getAspectID(self, aspect_name):
290 """Returns id of an aspect of given name.
291 Returns -1 if aspect is not found.
292
293 :param aspect_name: aspect name (must be spelled exactly as when created)
294 :type aspect_name: str
295 :returns: int
296 """
297 id = -1
298 aspects = self._connection.getUserInfo()['aspects']
299 for aspect in aspects:
300 if aspect['name'] == aspect_name: id = aspect['id']
301 return id
302
303 def filterByIDs(self, ids):
304 self._location += '?{0}'.format(','.join(ids))
305 self.fill()
306
307 def add(self, aspect_name, visible=0):
308 """This function adds a new aspect.
309 Status code 422 is accepted because it is returned by D* when
310 you try to add aspect already present on your aspect list.
311
312 :returns: Aspect() object of just created aspect
313 """
314 data = {'authenticity_token': self._connection.get_token(),
315 'aspect[name]': aspect_name,
316 'aspect[contacts_visible]': visible}
317
318 request = self._connection.post('aspects', data=data)
319 if request.status_code not in [200, 422]:
320 raise Exception('wrong status code: {0}'.format(request.status_code))
321
322 id = self.getAspectID(aspect_name)
323 return Aspect(self._connection, id)
324
325 def remove(self, aspect_id=-1, name=''):
326 """This method removes an aspect.
327 You can give it either id or name of the aspect.
328 When both are specified, id takes precedence over name.
329
330 Status code 500 is accepted because although the D* will
331 go nuts it will remove the aspect anyway.
332
333 :param aspect_id: id fo aspect to remove
334 :type aspect_id: int
335 :param name: name of aspect to remove
336 :type name: str
337 """
338 if aspect_id == -1 and name: aspect_id = self.getAspectID(name)
339 data = {'_method': 'delete',
340 'authenticity_token': self._connection.get_token()}
341 request = self._connection.post('aspects/{0}'.format(aspect_id), data=data)
342 if request.status_code not in [200, 302, 500]:
343 raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code))
344
345
346 class Commented(Generic):
347 """This stream contains all posts
348 the user has made a comment on.
349 """
350 _location = 'commented.json'
351
352
353 class Liked(Generic):
354 """This stream contains all posts the user liked.
355 """
356 _location = 'liked.json'
357
358
359 class Mentions(Generic):
360 """This stream contains all posts
361 the user is mentioned in.
362 """
363 _location = 'mentions.json'
364
365
366 class FollowedTags(Generic):
367 """This stream contains all posts
368 containing tags the user is following.
369 """
370 _location = 'followed_tags.json'
371
372 def remove(self, tag_id):
373 """Stop following a tag.
374
375 :param tag_id: tag id
376 :type tag_id: int
377 """
378 data = {'authenticity_token': self._connection.get_token()}
379 request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data)
380 if request.status_code != 404:
381 raise Exception('wrong status code: {0}'.format(request.status_code))
382
383 def add(self, tag_name):
384 """Follow new tag.
385 Error code 403 is accepted because pods respod with it when request
386 is sent to follow a tag that a user already follows.
387
388 :param tag_name: tag name
389 :type tag_name: str
390 :returns: int (response code)
391 """
392 data = {'name': tag_name,
393 'authenticity_token': self._connection.get_token(),
394 }
395 headers = {'content-type': 'application/json',
396 'x-csrf-token': self._connection.get_token(),
397 'accept': 'application/json'
398 }
399
400 request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers)
401
402 if request.status_code not in [201, 403]:
403 raise Exception('wrong error code: {0}'.format(request.status_code))
404 return request.status_code
405
406
407 class Tag(Generic):
408 """This stream contains all posts containing a tag.
409 """
410 def __init__(self, connection, tag):
411 """
412 :param connection: Connection() object
413 :type connection: diaspy.connection.Connection
414 :param tag: tag name
415 :type tag: str
416 """
417 self._connection = connection
418 self._location = 'tags/{0}.json'.format(tag)
419 self.fill()