Fetching notifications works again, fixes #31
[diaspy.git] / diaspy / models.py
CommitLineData
88ec6cda 1#!/usr/bin/env python3
ae221396
MK
2
3
e7abf853
MM
4"""This module is only imported in other diaspy modules and
5MUST NOT import anything.
6"""
7
8
178faa46 9import json
c8438f07 10import re
178faa46 11
9896b779
MM
12from diaspy import errors
13
178faa46 14
7a818fdb
MM
15class 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
141class 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
207class 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': '&#x2713;',
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
273class 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 305class 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]