One-to-rule-them-all version of `post()` method (with tests)
[diaspy.git] / diaspy / streams.py
1 import json
2 from diaspy.models import Post
3
4 """Docstrings for this module are taken from:
5 https://gist.github.com/MrZYX/01c93096c30dc44caf71
6
7 Documentation for D* JSON API taken from:
8 http://pad.spored.de/ro/r.qWmvhSZg7rk4OQam
9 """
10
11 class Generic:
12 """Object representing generic stream. Used in Tag(),
13 Stream(), Activity() etc.
14 """
15 def __init__(self, connection, location=''):
16 """
17 :param connection: Connection() object
18 :type connection: diaspy.connection.Connection
19 :param location: location of json
20 :type location: str
21 """
22 self._connection = connection
23 self._setlocation()
24 if location: self._location = location
25 self._stream = []
26 self.fill()
27
28 def __contains__(self, post):
29 """Returns True if stream contains given post.
30 """
31 if type(post) is not Post:
32 raise TypeError('stream can contain only posts: checked for {0}'.format(type(post)))
33 return post in self._stream
34
35 def __iter__(self):
36 """Provides iterable interface for stream.
37 """
38 return iter(self._stream)
39
40 def __getitem__(self, n):
41 """Returns n-th item in Stream.
42 """
43 return self._stream[n]
44
45 def __len__(self):
46 """Returns length of the Stream.
47 """
48 return len(self._stream)
49
50 def _setlocation(self):
51 """Sets location of the stream.
52 Location defaults to 'stream.json'
53
54 NOTICE: inheriting objects should override this method
55 and set their own value to the `location`.
56 However, it is possible to override default location by
57 passing the desired one to the constructor.
58 For example:
59
60 def _setlocation(self):
61 self._location = 'foo.json'
62
63
64 :param location: url of the stream
65 :type location: str
66
67 :returns: str
68 """
69 self._location = 'stream.json'
70
71 def _obtain(self):
72 """Obtains stream from pod.
73 """
74 request = self._connection.get(self._location)
75 if request.status_code != 200:
76 raise Exception('wrong status code: {0}'.format(request.status_code))
77 return [Post(str(post['id']), self._connection) for post in request.json()]
78
79 def clear(self):
80 """Removes all posts from stream.
81 """
82 self._stream = []
83
84 def purge(self):
85 """Removes all unexistent posts from stream.
86 """
87 stream = []
88 for post in self._stream:
89 deleted = False
90 try:
91 post.get_data()
92 stream.append(post)
93 except Exception:
94 deleted = True
95 finally:
96 if not deleted: stream.append(post)
97 self._stream = stream
98
99 def update(self):
100 """Updates stream.
101 """
102 new_stream = self._obtain()
103 ids = [post.post_id for post in self._stream]
104
105 stream = self._stream
106 for i in range(len(new_stream)):
107 if new_stream[-i].post_id not in ids:
108 stream = [new_stream[-i]] + stream
109 ids.append(new_stream[-i].post_id)
110
111 self._stream = stream
112
113 def fill(self):
114 """Fills the stream with posts.
115 """
116 self._stream = self._obtain()
117
118
119 class Outer(Generic):
120 """Object used by diaspy.models.User to represent
121 stream of other user.
122 """
123 def _obtain(self):
124 """Obtains stream of other user.
125 """
126 request = self._connection.get(self._location)
127 if request.status_code != 200:
128 raise Exception('wrong status code: {0}'.format(request.status_code))
129 return [Post(str(post['id']), self._connection) for post in request.json()]
130
131
132 class Stream(Generic):
133 """The main stream containing the combined posts of the
134 followed users and tags and the community spotlights posts
135 if the user enabled those.
136 """
137 def _setlocation(self):
138 self._location = 'stream.json'
139
140 def post(self, text='', aspect_ids='public', photos=None, photo=''):
141 """This function sends a post to an aspect.
142 If both `photo` and `photos` are specified `photos` takes precedence.
143
144 :param text: Text to post.
145 :type text: str
146 :param aspect_ids: Aspect ids to send post to.
147 :type aspect_ids: str
148 :param photo: filename of photo to post
149 :type photo: str
150 :param photos: id of photo to post (obtained from _photoupload())
151 :type photos: int
152
153 :returns: diaspy.models.Post -- the Post which has been created
154 """
155 data = {}
156 data['aspect_ids'] = aspect_ids
157 data['status_message'] = {'text': text}
158 if photo: data['photos'] = self._photoupload(photo)
159 if photos: data['photos'] = photos
160
161 request = self._connection.post('status_messages',
162 data=json.dumps(data),
163 headers={'content-type': 'application/json',
164 'accept': 'application/json',
165 'x-csrf-token': self._connection.get_token()})
166 if request.status_code != 201:
167 raise Exception('{0}: Post could not be posted.'.format(request.status_code))
168
169 post = Post(str(request.json()['id']), self._connection)
170 return post
171
172 def _photoupload(self, filename):
173 """Uploads picture to the pod.
174
175 :param filename: path to picture file
176 :type filename: str
177
178 :returns: id of the photo being uploaded
179 """
180 data = open(filename, 'rb')
181 image = data.read()
182 data.close()
183
184 params = {}
185 params['photo[pending]'] = 'true'
186 params['set_profile_image'] = ''
187 params['qqfile'] = filename
188 aspects = self._connection.getUserInfo()['aspects']
189 for i, aspect in enumerate(aspects):
190 params['photo[aspect_ids][{0}]'.format(i)] = aspect['id']
191
192 headers = {'content-type': 'application/octet-stream',
193 'x-csrf-token': self._connection.get_token(),
194 'x-file-name': filename}
195
196 request = self._connection.post('photos', data=image, params=params, headers=headers)
197 if request.status_code != 200:
198 raise Exception('wrong error code: {0}'.format(request.status_code))
199 return request.json()['data']['photo']['id']
200
201
202 class Activity(Generic):
203 """Stream representing user's activity.
204 """
205 def _setlocation(self):
206 self._location = 'activity.json'
207
208 def _delid(self, id):
209 """Deletes post with given id.
210 """
211 post = None
212 for p in self._stream:
213 if p['id'] == id:
214 post = p
215 break
216 if post is not None: post.delete()
217
218 def delete(self, post):
219 """Deletes post from users activity.
220 `post` can be either post id or Post()
221 object which will be identified and deleted.
222 After deleting post the stream will be filled.
223
224 :param post: post identifier
225 :type post: str, diaspy.models.Post
226 """
227 if type(post) == str: self._delid(post)
228 elif type(post) == Post: post.delete()
229 else:
230 raise TypeError('this method accepts only int, str or Post: {0} given')
231 self.fill()
232
233
234 class Aspects(Generic):
235 """This stream contains the posts filtered by
236 the specified aspect IDs. You can choose the aspect IDs with
237 the parameter `aspect_ids` which value should be
238 a comma seperated list of aspect IDs.
239 If the parameter is ommitted all aspects are assumed.
240 An example call would be `aspects.json?aspect_ids=23,5,42`
241 """
242 def _setlocation(self):
243 self._location = 'aspects.json'
244
245 def add(self, aspect_name, visible=0):
246 """This function adds a new aspect.
247 """
248 data = {'authenticity_token': self._connection.get_token(),
249 'aspect[name]': aspect_name,
250 'aspect[contacts_visible]': visible}
251
252 r = self._connection.post('aspects', data=data)
253 if r.status_code != 200:
254 raise Exception('wrong status code: {0}'.format(r.status_code))
255
256 def remove(self, aspect_id):
257 """This method removes an aspect.
258 """
259 data = {'authenticity_token': self.connection.get_token()}
260 r = self.connection.delete('aspects/{}'.format(aspect_id),
261 data=data)
262 if r.status_code != 404:
263 raise Exception('wrong status code: {0}'.format(r.status_code))
264
265
266 class Commented(Generic):
267 """This stream contains all posts
268 the user has made a comment on.
269 """
270 def _setlocation(self):
271 self._location = 'commented.json'
272
273
274 class Liked(Generic):
275 """This stream contains all posts the user liked.
276 """
277 def _setlocation(self):
278 self._location = 'liked.json'
279
280
281 class Mentions(Generic):
282 """This stream contains all posts
283 the user is mentioned in.
284 """
285 def _setlocation(self):
286 self._location = 'mentions.json'
287
288
289 class FollowedTags(Generic):
290 """This stream contains all posts
291 containing tags the user is following.
292 """
293 def _setlocation(self):
294 self._location = 'followed_tags.json'
295
296 def remove(self, tag_id):
297 """Stop following a tag.
298
299 :param tag_id: tag id
300 :type tag_id: int
301 """
302 data = {'authenticity_token':self._connection.get_token()}
303 request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data)
304 if request.status_code != 404:
305 raise Exception('wrong status code: {0}'.format(request.status_code))
306
307 def add(self, tag_name):
308 """Follow new tag.
309 Error code 403 is accepted because pods respod with it when request
310 is sent to follow a tag that a user already follows.
311
312 :param tag_name: tag name
313 :type tag_name: str
314 :returns: int (response code)
315 """
316 data = {'name':tag_name,
317 'authenticity_token':self._connection.get_token(),
318 }
319 headers={'content-type': 'application/json',
320 'x-csrf-token': self._connection.get_token(),
321 'accept': 'application/json'}
322
323 request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers)
324
325 if request.status_code not in [201, 403]:
326 raise Exception('wrong error code: {0}'.format(request.status_code))
327 return request.status_code