Commit | Line | Data |
---|---|---|
505fc964 MM |
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 | """ | |
1232dac5 | 7 | |
f605e88d | 8 | |
4b1645dc MM |
9 | import json |
10 | import time | |
11 | from diaspy.models import Post, Aspect | |
12 | from diaspy import errors | |
13 | ||
14 | ||
7a818fdb MM |
15 | class 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 |
136 | class 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 | 151 | class 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 | 220 | class 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 | ||
250 | class 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 | |
317 | class 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 | ||
324 | class Liked(Generic): | |
325 | """This stream contains all posts the user liked. | |
326 | """ | |
27a28aaf | 327 | _location = 'liked.json' |
505fc964 MM |
328 | |
329 | ||
330 | class 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 | ||
337 | class 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 | |
378 | class 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() |