IMPORTANT: these changes will require dateutil or pytz
[diaspy.git] / diaspy / people.py
CommitLineData
74edacee
MM
1#!/usr/bin/env python3
2
3import json
beaa09fb 4import re
03ffc9ea 5import warnings
0c28bf0b 6import time
03ffc9ea 7
beaa09fb 8from diaspy.streams import Outer
d589deff 9from diaspy.models import Aspect
65b1f099 10from diaspy import errors
7c6fbe5b 11from diaspy import search
beaa09fb
MM
12
13
6d8d47ce
MM
14def sephandle(handle):
15 """Separate Diaspora* handle into pod pod and user.
16
17 :returns: two-tuple (pod, user)
18 """
19 if re.match('^[a-zA-Z]+[a-zA-Z0-9_-]*@[a-z0-9.]+\.[a-z]+$', handle) is None:
615edb73 20 raise errors.InvalidHandleError('{0}'.format(handle))
6d8d47ce
MM
21 handle = handle.split('@')
22 pod, user = handle[1], handle[0]
23 return (pod, user)
24
25
488d7ff6 26class User():
beaa09fb
MM
27 """This class abstracts a D* user.
28 This object goes around the limitations of current D* API and will
29 extract user data using black magic.
30 However, no chickens are harmed when you use it.
17d8f406 31
7b99bf75
JR
32 The parameter fetch should be either 'posts', 'data' or 'none'. By
33 default it is 'posts' which means in addition to user data, stream
3cf4514e
MM
34 will be fetched. If user has not posted yet diaspy will not be able
35 to extract the information from his/her posts. Since there is no official
36 way to do it we rely on user posts. If this will be the case user
7b99bf75 37 will be notified with appropriate exception message.
3cf4514e 38
7b99bf75
JR
39 If fetch is 'data', only user data will be fetched. If the user is
40 not found, no exception will be returned.
41
42 When creating new User() one can pass either guid, handle and/or id as
43 optional parameters. GUID takes precedence over handle when fetching
44 user stream. When fetching user data, handle is required.
beaa09fb 45 """
d84905af
MM
46 @classmethod
47 def parse(cls, connection, data):
48 person = data.get('person')
49 if person is None:
50 raise errors.KeyMissingFromFetchedData('person', data)
51
52 guid = person.get('guid')
53 if guid is None:
54 raise errors.KeyMissingFromFetchedData('guid', person)
55
56 handle = person.get('diaspora_id')
57 if handle is None:
58 raise errors.KeyMissingFromFetchedData('diaspora_id', person)
59
60 person_id = person.get('id')
61 if person_id is None:
62 raise errors.KeyMissingFromFetchedData('id', person)
63
0c28bf0b 64 return User(connection, guid, handle, id, data=data)
d84905af 65
0c28bf0b 66 def __init__(self, connection, guid='', handle='', fetch='posts', id=0, data=None):
beaa09fb 67 self._connection = connection
6cd1bae0 68 self.stream = []
b9fb4030
JR
69 self.data = {
70 'guid': guid,
71 'handle': handle,
1e192d06 72 'id': id,
b9fb4030 73 }
0c28bf0b
C
74 self.photos = []
75 if data: self.data.update( data )
76 if fetch: self._fetch(fetch)
beaa09fb
MM
77
78 def __getitem__(self, key):
79 return self.data[key]
80
1e192d06 81 def __str__(self):
0f452d5d 82 return self.data.get('guid', '<guid missing>')
1e192d06
MM
83
84 def __repr__(self):
0f452d5d 85 return '{0} ({1})'.format(self.handle(), self.guid())
1e192d06 86
87cf6f93 87 def handle(self):
0c28bf0b 88 if 'handle' in self.data: return self['handle']
87cf6f93
MM
89 return self.data.get('diaspora_id', 'Unknown handle')
90
91 def guid(self):
92 return self.data.get('guid', '<guid missing>')
93
e2c368a8
MM
94 def id(self):
95 return self.data['id']
96
415337b6 97 def _fetchstream(self):
e4c9633a 98 self.stream = Outer(self._connection, guid=self['guid'])
415337b6 99
9aa1c960
MM
100 def _fetch(self, fetch):
101 """Fetch user posts or data.
102 """
b9fb4030 103 if fetch == 'posts':
0c28bf0b 104 if self.handle() and not self['guid']: self.fetchhandle()
9aa1c960 105 else: self.fetchguid()
f50cbea3 106 elif fetch == 'data' and self['handle']:
1f779f83 107 self.fetchprofile()
b9fb4030 108
7c6fbe5b 109 def _finalize_data(self, data):
6d8d47ce 110 """Adjustments are needed to have similar results returned
f50cbea3 111 by search feature and fetchguid()/fetchhandle().
6d8d47ce 112 """
d4f2d9ac 113 return data
beaa09fb 114
a8fdc14a
MM
115 def _postproc(self, request):
116 """Makes necessary modifications to user data and
117 sets up a stream.
118
119 :param request: request object
120 :type request: request
beaa09fb 121 """
6d8d47ce 122 if request.status_code != 200: raise Exception('wrong error code: {0}'.format(request.status_code))
d4f2d9ac
MM
123 data = request.json()
124 self.data = self._finalize_data(data)
beaa09fb 125
1f779f83 126 def fetchhandle(self, protocol='https'):
7b99bf75 127 """Fetch user data and posts using Diaspora handle.
beaa09fb 128 """
f50cbea3 129 pod, user = sephandle(self['handle'])
73a9e0d3 130 request = self._connection.get('{0}://{1}/u/{2}.json'.format(protocol, pod, user), direct=True)
a8fdc14a 131 self._postproc(request)
415337b6 132 self._fetchstream()
beaa09fb 133
5ef21061
C
134 def fetchguid(self, fetch_stream=True):
135 """Fetch user data and posts (if fetch_stream is True) using guid.
beaa09fb 136 """
f50cbea3
MM
137 if self['guid']:
138 request = self._connection.get('people/{0}.json'.format(self['guid']))
3311aa0c 139 self._postproc(request)
5ef21061 140 if fetch_stream: self._fetchstream()
3311aa0c
MM
141 else:
142 raise errors.UserError('GUID not set')
3cf4514e 143
5131bd9e
MM
144 def fetchprofile(self):
145 """Fetches user data.
0c28bf0b
C
146 """
147 data = search.Search(self._connection).user(self.handle())
03ffc9ea 148 if not data:
0c28bf0b 149 raise errors.UserError('user with handle "{0}" has not been found on pod "{1}"'.format(self.handle(), self._connection.pod))
03ffc9ea 150 else:
0c28bf0b 151 self.data.update( data[0] )
5131bd9e 152
564efb9e 153 def aspectMemberships(self):
0c28bf0b
C
154 if 'contact' in self.data:
155 return self.data.get('contact', {}).get('aspect_memberships', [])
156 else:
157 return self.data.get('aspect_memberships', [])
158
159 def getPhotos(self):
160 """
161 --> GET /people/{GUID}/photos.json HTTP/1.1
162
163 <-- HTTP/1.1 200 OK
164
165 {
166 "photos":[
167 {
168 "id":{photo_id},
169 "guid":"{photo_guid}",
170 "created_at":"2018-03-08T23:48:31.000Z",
171 "author":{
172 "id":{author_id},
173 "guid":"{author_guid}",
174 "name":"{author_name}",
175 "diaspora_id":"{diaspora_id}",
176 "avatar":{"small":"{avatar_url_small}","medium":"{avatar_url_medium}","large":"{avatar_url_large}"}
177 },
178 "sizes":{
179 "small":"{photo_url}",
180 "medium":"{photo_url}",
181 "large":"{photo_url}"
182 },
183 "dimensions":{"height":847,"width":998},
184 "status_message":{
185 "id":{post_id}
186 }
187 },{ ..
188 }
189
190 if there are no photo's it returns:
191 {"photos":[]}
192 """
193
194 request = self._connection.get('/people/{0}/photos.json'.format(self['guid']))
195 if request.status_code != 200: raise errors.UserError('could not fetch photos for user: {0}'.format(self['guid']))
196
197 json = request.json()
198 if json: self.photos = json['photos']
199 return json['photos']
564efb9e 200
39af9756 201 def getHCard(self):
0c28bf0b
C
202 """Returns json containing user HCard.
203 --> /people/{guid}/hovercard.json?_={timestamp}
204
205 <-- HTTP/2.0 200 OK
206 {
207 "id":123,
208 "guid":"1234567890abcdef",
209 "name":"test",
210 "diaspora_id":"batman@test.test",
211 "contact":false,
212 "profile":{
213 "avatar":"https://nicetesturl.url/image.jpg",
214 "tags":["tag1", "tag2", "tag3", "tag4", "tag5"]}
215 }
39af9756 216 """
0c28bf0b
C
217 timestamp = int(time.mktime(time.gmtime()))
218 request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp))
39af9756 219 if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid']))
0c28bf0b 220 return request.json()
39af9756 221
0c28bf0b
C
222 def deletePhoto(self, photo_id):
223 """
224 --> DELETE /photos/{PHOTO_ID} HTTP/1.1
225 <-- HTTP/1.1 204 No Content
226 """
227 request = self._connection.delete('/photos/{0}'.format(photo_id))
228 if request.status_code != 204: raise errors.UserError('could not delete photo_id: {0}'.format(photo_id))
dd0a4d9f 229
f8752360
MM
230class Me():
231 """Object represetnting current user.
232 """
233 _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})')
234 _userinfo_regex_2 = re.compile(r'gon.user=({.*});gon.preloads')
235
236 def __init__(self, connection):
237 self._connection = connection
238
39af9756 239 def getInfo(self):
f8752360
MM
240 """This function returns the current user's attributes.
241
1e578db9 242 :returns: dict
f8752360
MM
243 """
244 request = self._connection.get('bookmarklet')
245 userdata = self._userinfo_regex.search(request.text)
246 if userdata is None: userdata = self._userinfo_regex_2.search(request.text)
247 if userdata is None: raise errors.DiaspyError('cannot find user data')
248 userdata = userdata.group(1)
249 return json.loads(userdata)
250
251
dd0a4d9f
MM
252class Contacts():
253 """This class represents user's list of contacts.
254 """
0c28bf0b 255 def __init__(self, connection, fetch=False, set=''):
dd0a4d9f 256 self._connection = connection
0c28bf0b
C
257 self.contacts = None
258 if fetch: self.contacts = self.get(set)
259
260 def __getitem__(self, index):
261 return self.contacts[index]
262
263 def addAspect(self, name, visible=False):
264 """
265 --> POST /aspects HTTP/1.1
266 --> {"person_id":null,"name":"test","contacts_visible":false}
267
268 <-- HTTP/1.1 200 OK
269
270 Add new aspect.
271
272 TODO: status_code's
273
274 :param name: aspect name to add
275 :type name: str
276 :param visible: sets if contacts in aspect are visible for each and other
277 :type visible: bool
278 :returns: JSON from request
279 """
280 data = {
281 'person_id': None,
282 'name': name,
283 'contacts_visible': visible
284 }
285 headers={'content-type': 'application/json',
286 'accept': 'application/json' }
287 request = self._connection.tokenFrom('contacts').post('aspects', headers=headers, data=json.dumps(data))
288
289 if request.status_code == 400:
290 raise errors.AspectError('duplicate record, aspect alreadt exists: {0}'.format(request.status_code))
291 elif request.status_code != 200:
292 raise errors.AspectError('wrong status code: {0}'.format(request.status_code))
293
294 new_aspect = request.json()
295 self._connection.userdata()['aspects'].append( new_aspect )
296
297 return new_aspect
298
299 def deleteAspect(self, aspect_id):
300 """
301 --> POST /aspects/{ASPECT_ID} HTTP/1.1
302 _method=delete&authenticity_token={TOKEN}
303 Content-Type: application/x-www-form-urlencoded
304
305 <-- HTTP/1.1 302 Found
306 Content-Type: text/html; charset=utf-8
307 """
308 request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format( aspect_id ))
309
310 if request.status_code != 200: # since we don't post but delete
311 raise errors.AspectError('wrong status code: {0}'.format(request.status_code))
dd0a4d9f 312
d589deff
MM
313 def add(self, user_id, aspect_ids):
314 """Add user to aspects of given ids.
dd0a4d9f 315
0c28bf0b 316 :param user_id: user id (not guid)
d589deff
MM
317 :type user_id: str
318 :param aspect_ids: list of aspect ids
319 :type aspect_ids: list
dd0a4d9f 320 """
0c28bf0b
C
321 # TODO update self.contacts
322 # Returns {"aspect_id":123,"person_id":123}
323 for aid in aspect_ids:
324 new_aspect_membership = Aspect(self._connection, aid).addUser(user_id)
325
326 # user.
327 if new_aspect_membership:
328 for user in self.contacts:
329 if int(user.data['person_id']) == int(user_id):
330 user.data['aspect_memberships'].append( new_aspect_membership )
331 return new_aspect_membership
27f09973 332
d589deff
MM
333 def remove(self, user_id, aspect_ids):
334 """Remove user from aspects of given ids.
27f09973 335
0c28bf0b 336 :param user_id: user id
d589deff
MM
337 :type user_id: str
338 :param aspect_ids: list of aspect ids
339 :type aspect_ids: list
27f09973 340 """
7a818fdb 341 for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id)
27f09973 342
0c28bf0b 343 def get(self, set='', page=0):
27f09973 344 """Returns list of user contacts.
d589deff
MM
345 Contact is a User() who is in one or more of user's
346 aspects.
347
7a818fdb
MM
348 By default, it will return list of users who are in
349 user's aspects.
350
d589deff
MM
351 If `set` is `all` it will also include users who only share
352 with logged user and are not in his/hers aspects.
7a818fdb 353
d589deff
MM
354 If `set` is `only_sharing` it will return users who are only
355 sharing with logged user and ARE NOT in his/hers aspects.
356
0c28bf0b
C
357 # On "All contacts" button diaspora
358 on the time of testing this I had 20 contacts and 10 that
359 where only sharing with me. So 30 in total.
360
361 --> GET /contacts?set=all HTTP/1.1
362 <-- HTTP/1.1 200 OK
363 returned 25 contacts (5 only sharing with me)
364
365 --> GET /contacts.json?page=1&set=all&_=1524410225376 HTTP/1.1
366 <-- HTTP/1.1 200 OK
367 returned the same list as before.
368
369 --> GET /contacts.json?page=2&set=all&_=1524410225377 HTTP/1.1
370 <-- HTTP/1.1 200 OK
371 returned the other 5 that where only sharing with me.
372
373 --> GET /contacts.json?page=3&set=all&_=1524410225378 HTTP/1.1
374 <-- HTTP/1.1 200 OK
375 returned empty list.
376
377 It appears that /contacts?set=all returns a maximum of 25
378 contacts.
379
380 So if /contacts?set=all returns 25 contacts then request next
381 page until page returns a list with less then 25. I don't see a
382 reason why we should request page=1 'cause the previous request
383 will be the same. So begin with page=2 if /contacts?set=all
384 returns 25.
385
d589deff
MM
386 :param set: if passed could be 'all' or 'only_sharing'
387 :type set: str
27f09973 388 """
d589deff 389 params = {}
0c28bf0b
C
390 if set:
391 params['set'] = set
392 params['_'] = int(time.mktime(time.gmtime()))
393 if page: params['page'] = page
d589deff
MM
394
395 request = self._connection.get('contacts.json', params=params)
27f09973
MM
396 if request.status_code != 200:
397 raise Exception('status code {0}: cannot get contacts'.format(request.status_code))
0c28bf0b
C
398
399 json = request.json()
400 users = [User.parse(self._connection, each) for each in json]
401 if len(json) == 25:
402 if not page: page = 1
403 users += self.get(set=set, page=page+1)
404 return users