IMPORTANT: these changes will require dateutil or pytz
[diaspy.git] / diaspy / people.py
1 #!/usr/bin/env python3
2
3 import json
4 import re
5 import warnings
6 import time
7
8 from diaspy.streams import Outer
9 from diaspy.models import Aspect
10 from diaspy import errors
11 from diaspy import search
12
13
14 def 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:
20 raise errors.InvalidHandleError('{0}'.format(handle))
21 handle = handle.split('@')
22 pod, user = handle[1], handle[0]
23 return (pod, user)
24
25
26 class User():
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.
31
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
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
37 will be notified with appropriate exception message.
38
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.
45 """
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
64 return User(connection, guid, handle, id, data=data)
65
66 def __init__(self, connection, guid='', handle='', fetch='posts', id=0, data=None):
67 self._connection = connection
68 self.stream = []
69 self.data = {
70 'guid': guid,
71 'handle': handle,
72 'id': id,
73 }
74 self.photos = []
75 if data: self.data.update( data )
76 if fetch: self._fetch(fetch)
77
78 def __getitem__(self, key):
79 return self.data[key]
80
81 def __str__(self):
82 return self.data.get('guid', '<guid missing>')
83
84 def __repr__(self):
85 return '{0} ({1})'.format(self.handle(), self.guid())
86
87 def handle(self):
88 if 'handle' in self.data: return self['handle']
89 return self.data.get('diaspora_id', 'Unknown handle')
90
91 def guid(self):
92 return self.data.get('guid', '<guid missing>')
93
94 def id(self):
95 return self.data['id']
96
97 def _fetchstream(self):
98 self.stream = Outer(self._connection, guid=self['guid'])
99
100 def _fetch(self, fetch):
101 """Fetch user posts or data.
102 """
103 if fetch == 'posts':
104 if self.handle() and not self['guid']: self.fetchhandle()
105 else: self.fetchguid()
106 elif fetch == 'data' and self['handle']:
107 self.fetchprofile()
108
109 def _finalize_data(self, data):
110 """Adjustments are needed to have similar results returned
111 by search feature and fetchguid()/fetchhandle().
112 """
113 return data
114
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
121 """
122 if request.status_code != 200: raise Exception('wrong error code: {0}'.format(request.status_code))
123 data = request.json()
124 self.data = self._finalize_data(data)
125
126 def fetchhandle(self, protocol='https'):
127 """Fetch user data and posts using Diaspora handle.
128 """
129 pod, user = sephandle(self['handle'])
130 request = self._connection.get('{0}://{1}/u/{2}.json'.format(protocol, pod, user), direct=True)
131 self._postproc(request)
132 self._fetchstream()
133
134 def fetchguid(self, fetch_stream=True):
135 """Fetch user data and posts (if fetch_stream is True) using guid.
136 """
137 if self['guid']:
138 request = self._connection.get('people/{0}.json'.format(self['guid']))
139 self._postproc(request)
140 if fetch_stream: self._fetchstream()
141 else:
142 raise errors.UserError('GUID not set')
143
144 def fetchprofile(self):
145 """Fetches user data.
146 """
147 data = search.Search(self._connection).user(self.handle())
148 if not data:
149 raise errors.UserError('user with handle "{0}" has not been found on pod "{1}"'.format(self.handle(), self._connection.pod))
150 else:
151 self.data.update( data[0] )
152
153 def aspectMemberships(self):
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']
200
201 def getHCard(self):
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 }
216 """
217 timestamp = int(time.mktime(time.gmtime()))
218 request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp))
219 if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid']))
220 return request.json()
221
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))
229
230 class 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
239 def getInfo(self):
240 """This function returns the current user's attributes.
241
242 :returns: dict
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
252 class Contacts():
253 """This class represents user's list of contacts.
254 """
255 def __init__(self, connection, fetch=False, set=''):
256 self._connection = connection
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))
312
313 def add(self, user_id, aspect_ids):
314 """Add user to aspects of given ids.
315
316 :param user_id: user id (not guid)
317 :type user_id: str
318 :param aspect_ids: list of aspect ids
319 :type aspect_ids: list
320 """
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
332
333 def remove(self, user_id, aspect_ids):
334 """Remove user from aspects of given ids.
335
336 :param user_id: user id
337 :type user_id: str
338 :param aspect_ids: list of aspect ids
339 :type aspect_ids: list
340 """
341 for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id)
342
343 def get(self, set='', page=0):
344 """Returns list of user contacts.
345 Contact is a User() who is in one or more of user's
346 aspects.
347
348 By default, it will return list of users who are in
349 user's aspects.
350
351 If `set` is `all` it will also include users who only share
352 with logged user and are not in his/hers aspects.
353
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
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
386 :param set: if passed could be 'all' or 'only_sharing'
387 :type set: str
388 """
389 params = {}
390 if set:
391 params['set'] = set
392 params['_'] = int(time.mktime(time.gmtime()))
393 if page: params['page'] = page
394
395 request = self._connection.get('contacts.json', params=params)
396 if request.status_code != 200:
397 raise Exception('status code {0}: cannot get contacts'.format(request.status_code))
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