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