Commit | Line | Data |
---|---|---|
88ec6cda | 1 | #!/usr/bin/env python3 |
ae221396 MK |
2 | |
3 | ||
e7abf853 MM |
4 | """This module is only imported in other diaspy modules and |
5 | MUST NOT import anything. | |
6 | """ | |
7 | ||
8 | ||
178faa46 | 9 | import json |
c8438f07 | 10 | import re |
178faa46 | 11 | |
9896b779 MM |
12 | from diaspy import errors |
13 | ||
178faa46 | 14 | |
7a818fdb MM |
15 | class Aspect(): |
16 | """This class represents an aspect. | |
3cf4514e | 17 | |
4b7d7125 JR |
18 | Class can be initialized by passing either an id and/or name as |
19 | parameters. | |
20 | If both are missing, an exception will be raised. | |
7a818fdb | 21 | """ |
4b7d7125 | 22 | def __init__(self, connection, id=None, name=None): |
7a818fdb | 23 | self._connection = connection |
4b7d7125 JR |
24 | self.id, self.name = id, name |
25 | if id and not name: | |
26 | self.name = self._findname() | |
27 | elif name and not id: | |
28 | self.id = self._findid() | |
29 | elif not id and not name: | |
4b1645dc | 30 | raise Exception('Aspect must be initialized with either an id or name') |
3cf4514e | 31 | |
e4544075 MM |
32 | def _findname(self): |
33 | """Finds name for aspect. | |
34 | """ | |
4b7d7125 | 35 | name = None |
e4544075 MM |
36 | aspects = self._connection.getUserInfo()['aspects'] |
37 | for a in aspects: | |
38 | if a['id'] == self.id: | |
39 | name = a['name'] | |
40 | break | |
41 | return name | |
3cf4514e | 42 | |
4b7d7125 JR |
43 | def _findid(self): |
44 | """Finds id for aspect. | |
45 | """ | |
46 | id = None | |
47 | aspects = self._connection.getUserInfo()['aspects'] | |
48 | for a in aspects: | |
49 | if a['name'] == self.name: | |
50 | id = a['id'] | |
51 | break | |
52 | return id | |
7a818fdb | 53 | |
65b1f099 MM |
54 | def _getajax(self): |
55 | """Returns HTML returned when editing aspects via web UI. | |
488d7ff6 | 56 | """ |
1513c0d9 | 57 | start_regexp = re.compile('<ul +class=["\']contacts["\'] *>') |
1513c0d9 | 58 | ajax = self._connection.get('aspects/{0}/edit'.format(self.id)).text |
08f03d26 | 59 | |
1513c0d9 MM |
60 | begin = ajax.find(start_regexp.search(ajax).group(0)) |
61 | end = ajax.find('</ul>') | |
65b1f099 MM |
62 | return ajax[begin:end] |
63 | ||
64 | def _extractusernames(self, ajax): | |
65 | """Extracts usernames and GUIDs from ajax returned by Diaspora*. | |
6fd7d9c1 | 66 | Returns list of two-tuples: (guid, diaspora_name). |
65b1f099 | 67 | """ |
cd18cc87 | 68 | userline_regexp = re.compile('<a href=["\']/people/[a-z0-9]{16,16}["\']>[\w()*@. -]+</a>') |
65b1f099 MM |
69 | return [(line[17:33], re.escape(line[35:-4])) for line in userline_regexp.findall(ajax)] |
70 | ||
6fd7d9c1 MM |
71 | def _extractpersonids(self, ajax, usernames): |
72 | """Extracts `person_id`s and usernames from ajax and list of usernames. | |
73 | Returns list of two-tuples: (username, id) | |
65b1f099 MM |
74 | """ |
75 | personid_regexp = 'alt=["\']{0}["\'] class=["\']avatar["\'] data-person_id=["\'][0-9]+["\']' | |
e4544075 MM |
76 | personids = [re.compile(personid_regexp.format(name)).search(ajax).group(0) for guid, name in usernames] |
77 | for n, line in enumerate(personids): | |
78 | i, id = -2, '' | |
1513c0d9 | 79 | while line[i].isdigit(): |
e4544075 | 80 | id = line[i] + id |
1513c0d9 | 81 | i -= 1 |
e4544075 | 82 | personids[n] = (usernames[n][1], id) |
6fd7d9c1 | 83 | return personids |
e4544075 | 84 | |
6fd7d9c1 MM |
85 | def _defineusers(self, ajax, personids): |
86 | """Gets users contained in this aspect by getting users who have `delete` method. | |
87 | """ | |
88 | method_regexp = 'data-method="delete" data-person_id="{0}"' | |
e4544075 | 89 | users = [] |
6fd7d9c1 MM |
90 | for name, id in personids: |
91 | if re.compile(method_regexp.format(id)).search(ajax): users.append(name) | |
1513c0d9 | 92 | return users |
488d7ff6 | 93 | |
6fd7d9c1 MM |
94 | def _getguids(self, users_in_aspect, usernames): |
95 | """Defines users contained in this aspect. | |
96 | """ | |
97 | guids = [] | |
98 | for guid, name in usernames: | |
99 | if name in users_in_aspect: guids.append(guid) | |
100 | return guids | |
101 | ||
102 | def getUsers(self): | |
103 | """Returns list of GUIDs of users who are listed in this aspect. | |
104 | """ | |
105 | ajax = self._getajax() | |
106 | usernames = self._extractusernames(ajax) | |
107 | personids = self._extractpersonids(ajax, usernames) | |
108 | users_in_aspect = self._defineusers(ajax, personids) | |
109 | return self._getguids(users_in_aspect, usernames) | |
110 | ||
7a818fdb MM |
111 | def addUser(self, user_id): |
112 | """Add user to current aspect. | |
113 | ||
114 | :param user_id: user to add to aspect | |
cd18cc87 MM |
115 | :type user_id: int |
116 | :returns: JSON from request | |
7a818fdb | 117 | """ |
9896b779 | 118 | data = {'authenticity_token': repr(self._connection), |
7a818fdb MM |
119 | 'aspect_id': self.id, |
120 | 'person_id': user_id} | |
121 | ||
122 | request = self._connection.post('aspect_memberships.json', data=data) | |
123 | ||
c5e41a94 | 124 | if request.status_code == 400: |
9896b779 | 125 | raise errors.AspectError('duplicate record, user already exists in aspect: {0}'.format(request.status_code)) |
c5e41a94 | 126 | elif request.status_code == 404: |
9896b779 | 127 | raise errors.AspectError('user not found from this pod: {0}'.format(request.status_code)) |
0d698df1 | 128 | elif request.status_code != 200: |
9896b779 | 129 | raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) |
7a818fdb MM |
130 | return request.json() |
131 | ||
132 | def removeUser(self, user_id): | |
133 | """Remove user from current aspect. | |
134 | ||
135 | :param user_id: user to remove from aspect | |
136 | :type user: int | |
137 | """ | |
138 | data = {'authenticity_token': self._connection.get_token(), | |
139 | 'aspect_id': self.id, | |
140 | 'person_id': user_id} | |
7a818fdb MM |
141 | request = self.connection.delete('aspect_memberships/{0}.json'.format(self.id), data=data) |
142 | ||
143 | if request.status_code != 200: | |
9896b779 | 144 | raise errors.AspectError('cannot remove user from aspect: {0}'.format(request.status_code)) |
7a818fdb MM |
145 | return request.json() |
146 | ||
147 | ||
178faa46 MM |
148 | class Notification(): |
149 | """This class represents single notification. | |
150 | """ | |
961277f6 | 151 | _who_regexp = re.compile(r'/people/[0-9a-f]+" class=\'hovercardable') |
c8438f07 | 152 | _when_regexp = re.compile(r'[0-9]{4,4}(-[0-9]{2,2}){2,2} [0-9]{2,2}(:[0-9]{2,2}){2,2} UTC') |
961277f6 MM |
153 | _aboutid_regexp = re.compile(r'/posts/[0-9a-f]+') |
154 | _htmltag_regexp = re.compile('</?[a-z]+( *[a-z_-]+=["\'].*?["\'])* */?>') | |
c8438f07 | 155 | |
178faa46 MM |
156 | def __init__(self, connection, data): |
157 | self._connection = connection | |
178faa46 MM |
158 | self.type = list(data.keys())[0] |
159 | self.data = data[self.type] | |
160 | self.id = self.data['id'] | |
161 | self.unread = self.data['unread'] | |
162 | ||
163 | def __getitem__(self, key): | |
c8438f07 MM |
164 | """Returns a key from notification data. |
165 | """ | |
178faa46 MM |
166 | return self.data[key] |
167 | ||
c8438f07 MM |
168 | def __str__(self): |
169 | """Returns notification note. | |
170 | """ | |
961277f6 | 171 | string = re.sub(self._htmltag_regexp, '', self.data['note_html']) |
c8438f07 | 172 | string = string.strip().split('\n')[0] |
4eb05209 | 173 | while ' ' in string: string = string.replace(' ', ' ') |
c8438f07 MM |
174 | return string |
175 | ||
176 | def __repr__(self): | |
177 | """Returns notification note with more details. | |
178 | """ | |
179 | return '{0}: {1}'.format(self.when(), str(self)) | |
180 | ||
63f1d9f1 | 181 | def about(self): |
961277f6 MM |
182 | """Returns id of post about which the notification is informing OR: |
183 | If the id is None it means that it's about user so .who() is called. | |
63f1d9f1 MM |
184 | """ |
185 | about = self._aboutid_regexp.search(self.data['note_html']) | |
186 | if about is None: about = self.who() | |
187 | else: about = int(about.group(0)[7:]) | |
188 | return about | |
189 | ||
c8438f07 | 190 | def who(self): |
19a4a78a | 191 | """Returns list of guids of the users who caused you to get the notification. |
c8438f07 | 192 | """ |
19a4a78a | 193 | return [who[8:24] for who in self._who_regexp.findall(self.data['note_html'])] |
c8438f07 MM |
194 | |
195 | def when(self): | |
196 | """Returns UTC time as found in note_html. | |
b0b4c46d | 197 | """ |
19a4a78a | 198 | return self._when_regexp.search(self.data['note_html']).group(0) |
b0b4c46d | 199 | |
178faa46 MM |
200 | def mark(self, unread=False): |
201 | """Marks notification to read/unread. | |
202 | Marks notification to read if `unread` is False. | |
203 | Marks notification to unread if `unread` is True. | |
204 | ||
205 | :param unread: which state set for notification | |
206 | :type unread: bool | |
207 | """ | |
fe2181b4 | 208 | headers = {'x-csrf-token': repr(self._connection)} |
178faa46 | 209 | params = {'set_unread': json.dumps(unread)} |
178faa46 MM |
210 | self._connection.put('notifications/{0}'.format(self['id']), params=params, headers=headers) |
211 | self.data['unread'] = unread | |
212 | ||
213 | ||
4882952f MM |
214 | class Conversation(): |
215 | """This class represents a conversation. | |
216 | ||
217 | .. note:: | |
218 | Remember that you need to have access to the conversation. | |
219 | """ | |
220 | def __init__(self, connection, id, fetch=True): | |
221 | """ | |
222 | :param conv_id: id of the post and not the guid! | |
223 | :type conv_id: str | |
224 | :param connection: connection object used to authenticate | |
225 | :type connection: connection.Connection | |
226 | """ | |
227 | self._connection = connection | |
228 | self.id = id | |
229 | self.data = {} | |
230 | if fetch: self._fetch() | |
231 | ||
232 | def _fetch(self): | |
233 | """Fetches JSON data representing conversation. | |
234 | """ | |
235 | request = self._connection.get('conversations/{}.json'.format(self.id)) | |
236 | if request.status_code == 200: | |
237 | self.data = request.json()['conversation'] | |
238 | else: | |
239 | raise errors.ConversationError('cannot download conversation data: {0}'.format(request.status_code)) | |
240 | ||
241 | def answer(self, text): | |
242 | """Answer that conversation | |
243 | ||
244 | :param text: text to answer. | |
245 | :type text: str | |
246 | """ | |
247 | data = {'message[text]': text, | |
248 | 'utf8': '✓', | |
249 | 'authenticity_token': repr(self._connection)} | |
250 | ||
251 | request = self._connection.post('conversations/{}/messages'.format(self.id), | |
252 | data=data, | |
253 | headers={'accept': 'application/json'}) | |
254 | if request.status_code != 200: | |
255 | raise errors.ConversationError('{0}: Answer could not be posted.' | |
256 | .format(request.status_code)) | |
257 | return request.json() | |
258 | ||
259 | def delete(self): | |
260 | """Delete this conversation. | |
261 | Has to be implemented. | |
262 | """ | |
263 | data = {'authenticity_token': repr(self._connection)} | |
264 | ||
265 | request = self._connection.delete('conversations/{0}/visibility/' | |
266 | .format(self.id), | |
267 | data=data, | |
268 | headers={'accept': 'application/json'}) | |
269 | ||
270 | if request.status_code != 404: | |
271 | raise errors.ConversationError('{0}: Conversation could not be deleted.' | |
272 | .format(request.status_code)) | |
273 | ||
274 | def get_subject(self): | |
275 | """Returns the subject of this conversation | |
276 | """ | |
277 | return self.data['subject'] | |
278 | ||
279 | ||
313fb305 MM |
280 | class Comment(): |
281 | """Represents comment on post. | |
282 | ||
283 | Does not require Connection() object. Note that you should not manually | |
284 | create `Comment()` objects -- they are designed to be created automatically | |
285 | by `Post()` objects. | |
286 | """ | |
287 | def __init__(self, data): | |
288 | self.data = data | |
289 | ||
290 | def __str__(self): | |
291 | """Returns comment's text. | |
292 | """ | |
293 | return self.data['text'] | |
294 | ||
295 | def __repr__(self): | |
296 | """Returns comments text and author. | |
297 | Format: AUTHOR (AUTHOR'S GUID): COMMENT | |
298 | """ | |
299 | return '{0} ({1}): {2}'.format(self.author(), self.author('guid'), str(self)) | |
300 | ||
301 | def when(self): | |
302 | """Returns time when the comment had been created. | |
303 | """ | |
304 | return self.data['created_at'] | |
305 | ||
306 | def author(self, key='name'): | |
307 | """Returns author of the comment. | |
308 | """ | |
309 | return self.data['author'][key] | |
310 | ||
311 | ||
dd0a4d9f | 312 | class Post(): |
ae221396 MK |
313 | """This class represents a post. |
314 | ||
315 | .. note:: | |
316 | Remember that you need to have access to the post. | |
ae221396 | 317 | """ |
313fb305 | 318 | def __init__(self, connection, id, fetch=True, comments=True): |
a7661afd | 319 | """ |
fe2181b4 MM |
320 | :param id: id or guid of the post |
321 | :type id: str | |
b95ffe83 MM |
322 | :param connection: connection object used to authenticate |
323 | :type connection: connection.Connection | |
313fb305 MM |
324 | :param fetch: defines whether to fetch post's data or not |
325 | :type fetch: bool | |
326 | :param comments: defines whether to fetch post's comments or not | |
327 | :type comments: bool | |
a7661afd | 328 | """ |
b95ffe83 | 329 | self._connection = connection |
fe2181b4 | 330 | self.id = id |
313fb305 MM |
331 | self.data = {} |
332 | self.comments = [] | |
333 | if fetch: self._fetchdata() | |
334 | if comments: self._fetchcomments() | |
8095ac8b | 335 | |
1467ec15 MM |
336 | def __repr__(self): |
337 | """Returns string containing more information then str(). | |
338 | """ | |
75a456f4 | 339 | return '{0} ({1}): {2}'.format(self.data['author']['name'], self.data['author']['guid'], self.data['text']) |
1467ec15 | 340 | |
66c3bb76 MM |
341 | def __str__(self): |
342 | """Returns text of a post. | |
343 | """ | |
75a456f4 | 344 | return self.data['text'] |
66c3bb76 | 345 | |
f61c14c1 MM |
346 | def __getitem__(self, key): |
347 | return self.data[key] | |
348 | ||
313fb305 | 349 | def _fetchdata(self): |
0e858bba MM |
350 | """This function retrieves data of the post. |
351 | """ | |
78cc478a | 352 | request = self._connection.get('posts/{0}.json'.format(self.id)) |
fe2181b4 | 353 | if request.status_code != 200: |
313fb305 | 354 | raise errors.PostError('{0}: could not fetch data for post: {1}'.format(request.status_code, self.id)) |
fe2181b4 MM |
355 | else: |
356 | self.data = request.json() | |
a993a4b6 | 357 | |
313fb305 MM |
358 | def _fetchcomments(self): |
359 | """Retireves comments for this post. | |
360 | """ | |
361 | request = self._connection.get('posts/{0}/comments.json'.format(self.id)) | |
362 | if request.status_code != 200: | |
363 | raise errors.PostError('{0}: could not fetch comments for post: {1}'.format(request.status_code, self.id)) | |
364 | else: | |
365 | self.comments = [Comment(c) for c in request.json()] | |
366 | ||
78cc478a MM |
367 | def update(self): |
368 | """Updates post data. | |
369 | """ | |
313fb305 MM |
370 | self._fetchdata() |
371 | self._fetchcomments() | |
78cc478a | 372 | |
a993a4b6 | 373 | def like(self): |
88ec6cda | 374 | """This function likes a post. |
df912114 | 375 | It abstracts the 'Like' functionality. |
a993a4b6 MK |
376 | |
377 | :returns: dict -- json formatted like object. | |
a993a4b6 | 378 | """ |
fe2181b4 | 379 | data = {'authenticity_token': repr(self._connection)} |
a993a4b6 | 380 | |
78cc478a | 381 | request = self._connection.post('posts/{0}/likes'.format(self.id), |
385e7ebe MM |
382 | data=data, |
383 | headers={'accept': 'application/json'}) | |
a7661afd | 384 | |
fe2181b4 MM |
385 | if request.status_code != 201: |
386 | raise errors.PostError('{0}: Post could not be liked.' | |
387 | .format(request.status_code)) | |
388 | return request.json() | |
a993a4b6 | 389 | |
a993a4b6 MK |
390 | def reshare(self): |
391 | """This function reshares a post | |
a993a4b6 | 392 | """ |
fe2181b4 MM |
393 | data = {'root_guid': self.data['guid'], |
394 | 'authenticity_token': repr(self._connection)} | |
8095ac8b | 395 | |
fe2181b4 | 396 | request = self._connection.post('reshares', |
385e7ebe MM |
397 | data=data, |
398 | headers={'accept': 'application/json'}) | |
fe2181b4 MM |
399 | if request.status_code != 201: |
400 | raise Exception('{0}: Post could not be reshared'.format(request.status_code)) | |
401 | return request.json() | |
a993a4b6 MK |
402 | |
403 | def comment(self, text): | |
404 | """This function comments on a post | |
405 | ||
a993a4b6 MK |
406 | :param text: text to comment. |
407 | :type text: str | |
a993a4b6 | 408 | """ |
a993a4b6 | 409 | data = {'text': text, |
fe2181b4 MM |
410 | 'authenticity_token': repr(self._connection)} |
411 | request = self._connection.post('posts/{0}/comments'.format(self.id), | |
385e7ebe MM |
412 | data=data, |
413 | headers={'accept': 'application/json'}) | |
a7661afd | 414 | |
fe2181b4 | 415 | if request.status_code != 201: |
9088535d | 416 | raise Exception('{0}: Comment could not be posted.' |
fe2181b4 MM |
417 | .format(request.status_code)) |
418 | return request.json() | |
ae221396 | 419 | |
e7abf853 MM |
420 | def delete(self): |
421 | """ This function deletes this post | |
422 | """ | |
423 | data = {'authenticity_token': repr(self._connection)} | |
424 | request = self._connection.delete('posts/{0}'.format(self.id), | |
425 | data=data, | |
426 | headers={'accept': 'application/json'}) | |
427 | if request.status_code != 204: | |
428 | raise errors.PostError('{0}: Post could not be deleted'.format(request.status_code)) | |
429 | ||
5e809c8b | 430 | def delete_comment(self, comment_id): |
a993a4b6 MK |
431 | """This function removes a comment from a post |
432 | ||
91d2d5dc B |
433 | :param comment_id: id of the comment to remove. |
434 | :type comment_id: str | |
a993a4b6 | 435 | """ |
fe2181b4 MM |
436 | data = {'authenticity_token': repr(self._connection)} |
437 | request = self._connection.delete('posts/{0}/comments/{1}' | |
438 | .format(self.id, | |
385e7ebe MM |
439 | comment_id), |
440 | data=data, | |
441 | headers={'accept': 'application/json'}) | |
a7661afd | 442 | |
fe2181b4 MM |
443 | if request.status_code != 204: |
444 | raise errors.PostError('{0}: Comment could not be deleted' | |
445 | .format(request.status_code)) | |
5e809c8b | 446 | |
e7abf853 MM |
447 | def delete_like(self): |
448 | """This function removes a like from a post | |
5e809c8b | 449 | """ |
e7abf853 MM |
450 | data = {'authenticity_token': self._connection.get_token()} |
451 | ||
452 | request = self._connection.delete('posts/{0}/likes/{1}' | |
453 | .format(self.id, | |
454 | self.data['interactions'] | |
455 | ['likes'][0]['id']), | |
456 | data=data) | |
fe2181b4 | 457 | if request.status_code != 204: |
e7abf853 MM |
458 | raise errors.PostError('{0}: Like could not be removed.' |
459 | .format(request.status_code)) | |
313fb305 MM |
460 | |
461 | def author(self, key='name'): | |
462 | """Returns author of the post. | |
463 | :param key: all keys available in data['author'] | |
464 | """ | |
465 | return self.data['author'][key] |