Code fixes, new features and refactoring in the field of user data and
[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 """
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
39af9756 36 aspects = self._connection.getUserData()['aspects']
e4544075
MM
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
39af9756 47 aspects = self._connection.getUserData()['aspects']
4b7d7125
JR
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 """
cf6a800f 138 data = {'authenticity_token': repr(self._connection),
7a818fdb
MM
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
148class 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 158 self.type = list(data.keys())[0]
6c692631
MM
159 self._data = data[self.type]
160 self.id = self._data['id']
161 self.unread = self._data['unread']
178faa46
MM
162
163 def __getitem__(self, key):
c8438f07
MM
164 """Returns a key from notification data.
165 """
6c692631 166 return self._data[key]
178faa46 167
c8438f07
MM
168 def __str__(self):
169 """Returns notification note.
170 """
6c692631 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 184 """
6c692631 185 about = self._aboutid_regexp.search(self._data['note_html'])
63f1d9f1
MM
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 """
6c692631 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 """
6c692631 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 210 self._connection.put('notifications/{0}'.format(self['id']), params=params, headers=headers)
6c692631 211 self._data['unread'] = unread
178faa46
MM
212
213
4882952f
MM
214class 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
6c692631 229 self._data = {}
4882952f
MM
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:
6c692631 237 self._data = request.json()['conversation']
4882952f
MM
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': '&#x2713;',
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.'
f50cbea3 256 .format(request.status_code))
4882952f
MM
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/'
f50cbea3
MM
266 .format(self.id),
267 data=data,
268 headers={'accept': 'application/json'})
4882952f
MM
269
270 if request.status_code != 404:
271 raise errors.ConversationError('{0}: Conversation could not be deleted.'
f50cbea3 272 .format(request.status_code))
4882952f
MM
273
274 def get_subject(self):
275 """Returns the subject of this conversation
276 """
6c692631 277 return self._data['subject']
4882952f
MM
278
279
313fb305
MM
280class Comment():
281 """Represents comment on post.
f50cbea3 282
313fb305
MM
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):
6c692631 288 self._data = data
313fb305
MM
289
290 def __str__(self):
291 """Returns comment's text.
292 """
6c692631 293 return self._data['text']
313fb305
MM
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 """
6c692631 304 return self._data['created_at']
313fb305
MM
305
306 def author(self, key='name'):
307 """Returns author of the comment.
308 """
6c692631 309 return self._data['author'][key]
313fb305
MM
310
311
dd0a4d9f 312class 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 """
fe783229 318 def __init__(self, connection, id=0, guid='', fetch=True, comments=True):
a7661afd 319 """
fe783229
MM
320 :param id: id of the post (GUID is recommended)
321 :type id: int
322 :param guid: GUID of the post
323 :type guid: str
b95ffe83
MM
324 :param connection: connection object used to authenticate
325 :type connection: connection.Connection
313fb305
MM
326 :param fetch: defines whether to fetch post's data or not
327 :type fetch: bool
fe783229 328 :param comments: defines whether to fetch post's comments or not (if True also data will be fetched)
313fb305 329 :type comments: bool
a7661afd 330 """
6c692631 331 if not (guid or id): raise TypeError('neither guid nor id was provided')
b95ffe83 332 self._connection = connection
fe2181b4 333 self.id = id
fe783229 334 self.guid = guid
6c692631 335 self._data = {}
313fb305
MM
336 self.comments = []
337 if fetch: self._fetchdata()
fe783229 338 if comments:
6c692631 339 if not self._data: self._fetchdata()
fe783229 340 self._fetchcomments()
8095ac8b 341
1467ec15
MM
342 def __repr__(self):
343 """Returns string containing more information then str().
344 """
39af9756 345 return '{0} ({1}): {2}'.format(self._data['author']['name'], self._data['author']['guid'], self._data['text'])
1467ec15 346
66c3bb76
MM
347 def __str__(self):
348 """Returns text of a post.
349 """
6c692631 350 return self._data['text']
66c3bb76 351
f61c14c1 352 def __getitem__(self, key):
6c692631 353 return self._data[key]
f61c14c1 354
fe783229
MM
355 def __dict__(self):
356 """Returns dictionary of posts data.
357 """
6c692631 358 return self._data
fe783229 359
313fb305 360 def _fetchdata(self):
0e858bba 361 """This function retrieves data of the post.
fe783229
MM
362
363 :returns: guid of post whose data was fetched
0e858bba 364 """
fe783229
MM
365 if self.id: id = self.id
366 if self.guid: id = self.guid
367 request = self._connection.get('posts/{0}.json'.format(id))
fe2181b4 368 if request.status_code != 200:
fe783229 369 raise errors.PostError('{0}: could not fetch data for post: {1}'.format(request.status_code, id))
fe2181b4 370 else:
6c692631 371 self._data = request.json()
fe783229 372 return self['guid']
a993a4b6 373
313fb305 374 def _fetchcomments(self):
fe783229 375 """Retreives comments for this post.
39af9756
MM
376 Retrieving comments via GUID will result in 404 error.
377 DIASPORA* does not supply comments through /posts/:guid/ endpoint.
fe783229 378 """
39af9756 379 id = self._data['id']
fe783229
MM
380 if self['interactions']['comments_count']:
381 request = self._connection.get('posts/{0}/comments.json'.format(id))
382 if request.status_code != 200:
383 raise errors.PostError('{0}: could not fetch comments for post: {1}'.format(request.status_code, id))
384 else:
385 self.comments = [Comment(c) for c in request.json()]
313fb305 386
78cc478a
MM
387 def update(self):
388 """Updates post data.
389 """
313fb305
MM
390 self._fetchdata()
391 self._fetchcomments()
78cc478a 392
a993a4b6 393 def like(self):
88ec6cda 394 """This function likes a post.
df912114 395 It abstracts the 'Like' functionality.
a993a4b6
MK
396
397 :returns: dict -- json formatted like object.
a993a4b6 398 """
fe2181b4 399 data = {'authenticity_token': repr(self._connection)}
a993a4b6 400
78cc478a 401 request = self._connection.post('posts/{0}/likes'.format(self.id),
f50cbea3
MM
402 data=data,
403 headers={'accept': 'application/json'})
a7661afd 404
fe2181b4
MM
405 if request.status_code != 201:
406 raise errors.PostError('{0}: Post could not be liked.'
f50cbea3 407 .format(request.status_code))
fe2181b4 408 return request.json()
a993a4b6 409
a993a4b6
MK
410 def reshare(self):
411 """This function reshares a post
a993a4b6 412 """
6c692631 413 data = {'root_guid': self._data['guid'],
fe2181b4 414 'authenticity_token': repr(self._connection)}
8095ac8b 415
fe2181b4 416 request = self._connection.post('reshares',
f50cbea3
MM
417 data=data,
418 headers={'accept': 'application/json'})
fe2181b4
MM
419 if request.status_code != 201:
420 raise Exception('{0}: Post could not be reshared'.format(request.status_code))
421 return request.json()
a993a4b6
MK
422
423 def comment(self, text):
424 """This function comments on a post
425
a993a4b6
MK
426 :param text: text to comment.
427 :type text: str
a993a4b6 428 """
a993a4b6 429 data = {'text': text,
fe2181b4
MM
430 'authenticity_token': repr(self._connection)}
431 request = self._connection.post('posts/{0}/comments'.format(self.id),
f50cbea3
MM
432 data=data,
433 headers={'accept': 'application/json'})
a7661afd 434
fe2181b4 435 if request.status_code != 201:
9088535d 436 raise Exception('{0}: Comment could not be posted.'
fe2181b4
MM
437 .format(request.status_code))
438 return request.json()
ae221396 439
e7abf853
MM
440 def delete(self):
441 """ This function deletes this post
442 """
443 data = {'authenticity_token': repr(self._connection)}
444 request = self._connection.delete('posts/{0}'.format(self.id),
f50cbea3
MM
445 data=data,
446 headers={'accept': 'application/json'})
e7abf853
MM
447 if request.status_code != 204:
448 raise errors.PostError('{0}: Post could not be deleted'.format(request.status_code))
449
5e809c8b 450 def delete_comment(self, comment_id):
a993a4b6
MK
451 """This function removes a comment from a post
452
91d2d5dc
B
453 :param comment_id: id of the comment to remove.
454 :type comment_id: str
a993a4b6 455 """
fe2181b4
MM
456 data = {'authenticity_token': repr(self._connection)}
457 request = self._connection.delete('posts/{0}/comments/{1}'
f50cbea3
MM
458 .format(self.id, comment_id),
459 data=data,
460 headers={'accept': 'application/json'})
a7661afd 461
fe2181b4
MM
462 if request.status_code != 204:
463 raise errors.PostError('{0}: Comment could not be deleted'
f50cbea3 464 .format(request.status_code))
5e809c8b 465
e7abf853
MM
466 def delete_like(self):
467 """This function removes a like from a post
5e809c8b 468 """
6c692631
MM
469 data = {'authenticity_token': repr(self._connection)}
470 url = 'posts/{0}/likes/{1}'.format(self.id, self._data['interactions']['likes'][0]['id'])
f50cbea3 471 request = self._connection.delete(url, data=data)
fe2181b4 472 if request.status_code != 204:
e7abf853 473 raise errors.PostError('{0}: Like could not be removed.'
f50cbea3 474 .format(request.status_code))
313fb305
MM
475
476 def author(self, key='name'):
477 """Returns author of the post.
478 :param key: all keys available in data['author']
479 """
6c692631 480 return self._data['author'][key]