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