Messages are conversations once again
[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
505fc964 20 def __init__(self, connection, location=''):
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
1232dac5
MM
26 """
27 self._connection = connection
505fc964 28 if location: self._location = location
63f1d9f1
MM
29 self._stream = []
30 # since epoch
31 self.max_time = int(time.mktime(time.gmtime()))
1232dac5
MM
32 self.fill()
33
34 def __contains__(self, post):
35 """Returns True if stream contains given post.
36 """
1232dac5
MM
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
ed366d44 54 def _obtain(self, max_time=0):
1232dac5
MM
55 """Obtains stream from pod.
56 """
ed366d44 57 params = {}
dde4ddb7
MM
58 if max_time:
59 params['max_time'] = max_time
60 params['_'] = self.max_time
ed366d44 61 request = self._connection.get(self._location, params=params)
1232dac5 62 if request.status_code != 200:
4b1645dc 63 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
78cc478a 64 return [Post(self._connection, post['id']) for post in request.json()]
1232dac5 65
ed366d44
MM
66 def _expand(self, new_stream):
67 """Appends older posts to stream.
68 """
dde4ddb7 69 ids = [post.id for post in self._stream]
ed366d44
MM
70 stream = self._stream
71 for post in new_stream:
dde4ddb7 72 if post.id not in ids:
ed366d44 73 stream.append(post)
dde4ddb7 74 ids.append(post.id)
ed366d44
MM
75 self._stream = stream
76
77 def _update(self, new_stream):
78 """Updates stream with new posts.
79 """
78cc478a 80 ids = [post.id for post in self._stream]
ed366d44
MM
81
82 stream = self._stream
83 for i in range(len(new_stream)):
78cc478a 84 if new_stream[-i].id not in ids:
ed366d44 85 stream = [new_stream[-i]] + stream
9aa1c960 86 ids.append(new_stream[-i].id)
ed366d44
MM
87 self._stream = stream
88
1232dac5
MM
89 def clear(self):
90 """Removes all posts from stream.
91 """
92 self._stream = []
93
505fc964
MM
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:
78cc478a 101 post.update()
505fc964
MM
102 stream.append(post)
103 except Exception:
104 deleted = True
105 finally:
106 if not deleted: stream.append(post)
107 self._stream = stream
108
1232dac5
MM
109 def update(self):
110 """Updates stream.
111 """
3cf4514e 112 self._update(self._obtain())
1232dac5
MM
113
114 def fill(self):
115 """Fills the stream with posts.
116 """
117 self._stream = self._obtain()
118
178faa46 119 def more(self, max_time=0):
33b34938 120 """Tries to download more (older ones) Posts from Stream.
ed366d44
MM
121
122 :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own)
123 :type max_time: int
33b34938 124 """
178faa46
MM
125 if not max_time: max_time = self.max_time - 3000000
126 self.max_time = max_time
ed366d44
MM
127 new_stream = self._obtain(max_time=max_time)
128 self._expand(new_stream)
33b34938 129
dde4ddb7
MM
130 def copy(self):
131 """Returns copy (list of posts) of current stream.
132 """
133 return [p for p in self._stream]
134
1232dac5 135
beaa09fb
MM
136class Outer(Generic):
137 """Object used by diaspy.models.User to represent
138 stream of other user.
139 """
dde4ddb7
MM
140 def _obtain(self, max_time=0):
141 """Obtains stream from pod.
beaa09fb 142 """
dde4ddb7
MM
143 params = {}
144 if max_time: params['max_time'] = max_time
145 request = self._connection.get(self._location, params=params)
beaa09fb 146 if request.status_code != 200:
dde4ddb7 147 raise errors.StreamError('wrong status code: {0}'.format(request.status_code))
78cc478a 148 return [Post(self._connection, post['id']) for post in request.json()]
beaa09fb
MM
149
150
1232dac5 151class Stream(Generic):
f605e88d
MM
152 """The main stream containing the combined posts of the
153 followed users and tags and the community spotlights posts
505fc964 154 if the user enabled those.
1232dac5 155 """
27a28aaf 156 location = 'stream.json'
505fc964 157
a98c6792
MM
158 def post(self, text='', aspect_ids='public', photos=None, photo=''):
159 """This function sends a post to an aspect.
160 If both `photo` and `photos` are specified `photos` takes precedence.
1232dac5
MM
161
162 :param text: Text to post.
163 :type text: str
164 :param aspect_ids: Aspect ids to send post to.
165 :type aspect_ids: str
a98c6792
MM
166 :param photo: filename of photo to post
167 :type photo: str
168 :param photos: id of photo to post (obtained from _photoupload())
169 :type photos: int
1232dac5
MM
170
171 :returns: diaspy.models.Post -- the Post which has been created
172 """
173 data = {}
174 data['aspect_ids'] = aspect_ids
175 data['status_message'] = {'text': text}
a98c6792 176 if photo: data['photos'] = self._photoupload(photo)
1232dac5 177 if photos: data['photos'] = photos
a98c6792 178
1232dac5
MM
179 request = self._connection.post('status_messages',
180 data=json.dumps(data),
181 headers={'content-type': 'application/json',
182 'accept': 'application/json',
78cc478a 183 'x-csrf-token': repr(self._connection)})
1232dac5 184 if request.status_code != 201:
a98c6792 185 raise Exception('{0}: Post could not be posted.'.format(request.status_code))
1232dac5 186
78cc478a 187 post = Post(self._connection, request.json()['id'])
1232dac5
MM
188 return post
189
66c3bb76
MM
190 def _photoupload(self, filename):
191 """Uploads picture to the pod.
1232dac5 192
66c3bb76 193 :param filename: path to picture file
1232dac5 194 :type filename: str
66c3bb76
MM
195
196 :returns: id of the photo being uploaded
1232dac5 197 """
38fabb63
MM
198 data = open(filename, 'rb')
199 image = data.read()
200 data.close()
201
1232dac5
MM
202 params = {}
203 params['photo[pending]'] = 'true'
204 params['set_profile_image'] = ''
205 params['qqfile'] = filename
38fabb63 206 aspects = self._connection.getUserInfo()['aspects']
1232dac5 207 for i, aspect in enumerate(aspects):
38fabb63 208 params['photo[aspect_ids][{0}]'.format(i)] = aspect['id']
1232dac5
MM
209
210 headers = {'content-type': 'application/octet-stream',
78cc478a 211 'x-csrf-token': repr(self._connection),
1232dac5 212 'x-file-name': filename}
38fabb63
MM
213
214 request = self._connection.post('photos', data=image, params=params, headers=headers)
215 if request.status_code != 200:
2d4f6eeb 216 raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code))
66c3bb76
MM
217 return request.json()['data']['photo']['id']
218
1232dac5 219
278febce 220class Activity(Stream):
1232dac5
MM
221 """Stream representing user's activity.
222 """
27a28aaf 223 _location = 'activity.json'
505fc964
MM
224
225 def _delid(self, id):
226 """Deletes post with given id.
227 """
228 post = None
229 for p in self._stream:
230 if p['id'] == id:
231 post = p
232 break
233 if post is not None: post.delete()
234
235 def delete(self, post):
236 """Deletes post from users activity.
237 `post` can be either post id or Post()
238 object which will be identified and deleted.
239 After deleting post the stream will be filled.
240
241 :param post: post identifier
242 :type post: str, diaspy.models.Post
243 """
244 if type(post) == str: self._delid(post)
245 elif type(post) == Post: post.delete()
63f1d9f1 246 else: raise TypeError('this method accepts str or Post types: {0} given')
505fc964
MM
247 self.fill()
248
249
250class Aspects(Generic):
f605e88d
MM
251 """This stream contains the posts filtered by
252 the specified aspect IDs. You can choose the aspect IDs with
253 the parameter `aspect_ids` which value should be
254 a comma seperated list of aspect IDs.
255 If the parameter is ommitted all aspects are assumed.
505fc964
MM
256 An example call would be `aspects.json?aspect_ids=23,5,42`
257 """
27a28aaf 258 _location = 'aspects.json'
27a28aaf 259
278febce 260 def getAspectID(self, aspect_name):
63cc182d
MM
261 """Returns id of an aspect of given name.
262 Returns -1 if aspect is not found.
263
278febce
MM
264 :param aspect_name: aspect name (must be spelled exactly as when created)
265 :type aspect_name: str
63cc182d
MM
266 :returns: int
267 """
268 id = -1
33b34938 269 aspects = self._connection.getUserInfo()['aspects']
278febce
MM
270 for aspect in aspects:
271 if aspect['name'] == aspect_name: id = aspect['id']
63cc182d
MM
272 return id
273
27a28aaf
MM
274 def filterByIDs(self, ids):
275 self._location += '?{0}'.format(','.join(ids))
276 self.fill()
277
505fc964
MM
278 def add(self, aspect_name, visible=0):
279 """This function adds a new aspect.
278febce 280 Status code 422 is accepted because it is returned by D* when
27a28aaf
MM
281 you try to add aspect already present on your aspect list.
282
d589deff 283 :returns: Aspect() object of just created aspect
505fc964 284 """
59ad210c 285 data = {'authenticity_token': self._connection.get_token(),
505fc964
MM
286 'aspect[name]': aspect_name,
287 'aspect[contacts_visible]': visible}
288
27a28aaf
MM
289 request = self._connection.post('aspects', data=data)
290 if request.status_code not in [200, 422]:
291 raise Exception('wrong status code: {0}'.format(request.status_code))
292
278febce 293 id = self.getAspectID(aspect_name)
d589deff 294 return Aspect(self._connection, id)
505fc964 295
278febce 296 def remove(self, aspect_id=-1, name=''):
fbb19900 297 """This method removes an aspect.
278febce
MM
298 You can give it either id or name of the aspect.
299 When both are specified, id takes precedence over name.
300
301 Status code 500 is accepted because although the D* will
27a28aaf
MM
302 go nuts it will remove the aspect anyway.
303
304 :param aspect_id: id fo aspect to remove
305 :type aspect_id: int
1467ec15
MM
306 :param name: name of aspect to remove
307 :type name: str
fbb19900 308 """
278febce 309 if aspect_id == -1 and name: aspect_id = self.getAspectID(name)
27f09973 310 data = {'_method': 'delete',
dd0a4d9f
MM
311 'authenticity_token': self._connection.get_token()}
312 request = self._connection.post('aspects/{0}'.format(aspect_id), data=data)
313 if request.status_code not in [200, 302, 500]:
314 raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code))
fbb19900 315
505fc964
MM
316
317class Commented(Generic):
f605e88d 318 """This stream contains all posts
505fc964
MM
319 the user has made a comment on.
320 """
27a28aaf 321 _location = 'commented.json'
505fc964
MM
322
323
324class Liked(Generic):
325 """This stream contains all posts the user liked.
326 """
27a28aaf 327 _location = 'liked.json'
505fc964
MM
328
329
330class Mentions(Generic):
f605e88d 331 """This stream contains all posts
505fc964
MM
332 the user is mentioned in.
333 """
27a28aaf 334 _location = 'mentions.json'
505fc964
MM
335
336
337class FollowedTags(Generic):
f605e88d 338 """This stream contains all posts
6c416e80 339 containing tags the user is following.
505fc964 340 """
27a28aaf 341 _location = 'followed_tags.json'
505fc964 342
a7c098e3 343 def remove(self, tag_id):
fbb19900
MM
344 """Stop following a tag.
345
a7c098e3
MM
346 :param tag_id: tag id
347 :type tag_id: int
fbb19900 348 """
f605e88d 349 data = {'authenticity_token': self._connection.get_token()}
a7c098e3
MM
350 request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data)
351 if request.status_code != 404:
352 raise Exception('wrong status code: {0}'.format(request.status_code))
fbb19900
MM
353
354 def add(self, tag_name):
505fc964 355 """Follow new tag.
f605e88d 356 Error code 403 is accepted because pods respod with it when request
6c416e80 357 is sent to follow a tag that a user already follows.
505fc964
MM
358
359 :param tag_name: tag name
360 :type tag_name: str
6c416e80 361 :returns: int (response code)
505fc964 362 """
f605e88d
MM
363 data = {'name': tag_name,
364 'authenticity_token': self._connection.get_token(),
365 }
366 headers = {'content-type': 'application/json',
367 'x-csrf-token': self._connection.get_token(),
368 'accept': 'application/json'
369 }
f0fa9fec
MK
370
371 request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers)
372
6c416e80 373 if request.status_code not in [201, 403]:
505fc964 374 raise Exception('wrong error code: {0}'.format(request.status_code))
6c416e80 375 return request.status_code
2ec93347 376
7a818fdb
MM
377
378class Tag(Generic):
379 """This stream contains all posts containing a tag.
380 """
ff3d2ab4 381 def __init__(self, connection, tag):
7a818fdb
MM
382 """
383 :param connection: Connection() object
384 :type connection: diaspy.connection.Connection
385 :param tag: tag name
386 :type tag: str
387 """
388 self._connection = connection
6cd1bae0 389 self._location = 'tags/{0}.json'.format(tag)
7a818fdb 390 self.fill()