* Removed `backtime` parameter from `diaspy.streams.Generic()` it's `more()` method...
[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 14def sephandle(handle):
d95ff94a 15 """Separate Diaspora* handle into pod pod and user.
6d8d47ce 16
d95ff94a
C
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)
6d8d47ce
MM
24
25
488d7ff6 26class User():
d95ff94a
C
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):
f54aff84
C
154 """Returns a list with aspect memberships
155
156 :returns: list
157 """
d95ff94a
C
158 if 'contact' in self.data:
159 return self.data.get('contact', {}).get('aspect_memberships', [])
160 else:
161 return self.data.get('aspect_memberships', [])
162
163 def getPhotos(self):
f54aff84
C
164 """Gets and sets this User()'s photo data.
165
166 :returns: dict
167
d95ff94a
C
168 --> GET /people/{GUID}/photos.json HTTP/1.1
169
170 <-- HTTP/1.1 200 OK
171
172 {
173 "photos":[
174 {
175 "id":{photo_id},
176 "guid":"{photo_guid}",
177 "created_at":"2018-03-08T23:48:31.000Z",
178 "author":{
179 "id":{author_id},
180 "guid":"{author_guid}",
181 "name":"{author_name}",
182 "diaspora_id":"{diaspora_id}",
183 "avatar":{"small":"{avatar_url_small}","medium":"{avatar_url_medium}","large":"{avatar_url_large}"}
184 },
185 "sizes":{
186 "small":"{photo_url}",
187 "medium":"{photo_url}",
188 "large":"{photo_url}"
189 },
190 "dimensions":{"height":847,"width":998},
191 "status_message":{
192 "id":{post_id}
193 }
194 },{ ..
195 }
196
197 if there are no photo's it returns:
198 {"photos":[]}
199 """
200
201 request = self._connection.get('/people/{0}/photos.json'.format(self['guid']))
202 if request.status_code != 200: raise errors.UserError('could not fetch photos for user: {0}'.format(self['guid']))
203
204 json = request.json()
205 if json: self.photos = json['photos']
206 return json['photos']
207
208 def getHCard(self):
209 """Returns json containing user HCard.
210 --> /people/{guid}/hovercard.json?_={timestamp}
211
212 <-- HTTP/2.0 200 OK
213 {
214 "id":123,
215 "guid":"1234567890abcdef",
216 "name":"test",
217 "diaspora_id":"batman@test.test",
218 "contact":false,
219 "profile":{
220 "avatar":"https://nicetesturl.url/image.jpg",
221 "tags":["tag1", "tag2", "tag3", "tag4", "tag5"]}
222 }
223 """
224 timestamp = int(time.mktime(time.gmtime()))
225 request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp))
226 if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid']))
227 return request.json()
228
229 def deletePhoto(self, photo_id):
230 """
f54aff84
C
231 :param photo_id: Photo ID to delete.
232 :type photo_id: int
233
d95ff94a
C
234 --> DELETE /photos/{PHOTO_ID} HTTP/1.1
235 <-- HTTP/1.1 204 No Content
236 """
237 request = self._connection.delete('/photos/{0}'.format(photo_id))
238 if request.status_code != 204: raise errors.UserError('could not delete photo_id: {0}'.format(photo_id))
dd0a4d9f 239
f8752360 240class Me():
d95ff94a
C
241 """Object represetnting current user.
242 """
243 _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})')
244 _userinfo_regex_2 = re.compile(r'gon.user=({.*});gon.preloads')
f8752360 245
d95ff94a
C
246 def __init__(self, connection):
247 self._connection = connection
f8752360 248
d95ff94a
C
249 def getInfo(self):
250 """This function returns the current user's attributes.
f8752360 251
d95ff94a
C
252 :returns: dict
253 """
254 request = self._connection.get('bookmarklet')
255 userdata = self._userinfo_regex.search(request.text)
256 if userdata is None: userdata = self._userinfo_regex_2.search(request.text)
257 if userdata is None: raise errors.DiaspyError('cannot find user data')
258 userdata = userdata.group(1)
259 return json.loads(userdata)
f8752360
MM
260
261
dd0a4d9f 262class Contacts():
d95ff94a
C
263 """This class represents user's list of contacts.
264 """
265 def __init__(self, connection, fetch=False, set=''):
266 self._connection = connection
267 self.contacts = None
268 if fetch: self.contacts = self.get(set)
269
270 def __getitem__(self, index):
271 return self.contacts[index]
272
273 def addAspect(self, name, visible=False):
f54aff84 274 """Add new aspect.
d95ff94a
C
275
276 :param name: aspect name to add
277 :type name: str
278 :param visible: sets if contacts in aspect are visible for each and other
279 :type visible: bool
280 :returns: JSON from request
f54aff84
C
281
282 --> POST /aspects HTTP/1.1
283 --> {"person_id":null,"name":"test","contacts_visible":false}
284
285 <-- HTTP/1.1 200 OK
d95ff94a
C
286 """
287 data = {
288 'person_id': None,
289 'name': name,
290 'contacts_visible': visible
291 }
292 headers={'content-type': 'application/json',
293 'accept': 'application/json' }
294 request = self._connection.tokenFrom('contacts').post('aspects', headers=headers, data=json.dumps(data))
295
296 if request.status_code == 400:
297 raise errors.AspectError('duplicate record, aspect alreadt exists: {0}'.format(request.status_code))
298 elif request.status_code != 200:
299 raise errors.AspectError('wrong status code: {0}'.format(request.status_code))
300
301 new_aspect = request.json()
302 self._connection.userdata()['aspects'].append( new_aspect )
303
304 return new_aspect
305
306 def deleteAspect(self, aspect_id):
f54aff84
C
307 """Deletes a aspect with given ID.
308
309 :param aspect_id: Aspect ID to delete.
310 :type aspect_id: int
311
d95ff94a
C
312 --> POST /aspects/{ASPECT_ID} HTTP/1.1
313 _method=delete&authenticity_token={TOKEN}
314 Content-Type: application/x-www-form-urlencoded
315
316 <-- HTTP/1.1 302 Found
317 Content-Type: text/html; charset=utf-8
318 """
319 request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format( aspect_id ))
320
321 if request.status_code != 200: # since we don't post but delete
322 raise errors.AspectError('wrong status code: {0}'.format(request.status_code))
323
324 def add(self, user_id, aspect_ids):
325 """Add user to aspects of given ids.
326
327 :param user_id: user id (not guid)
328 :type user_id: str
329 :param aspect_ids: list of aspect ids
330 :type aspect_ids: list
f54aff84 331 :returns: dict
d95ff94a
C
332 """
333 # TODO update self.contacts
d95ff94a
C
334 for aid in aspect_ids:
335 new_aspect_membership = Aspect(self._connection, aid).addUser(user_id)
336
337 # user.
338 if new_aspect_membership:
339 for user in self.contacts:
340 if int(user.data['person_id']) == int(user_id):
341 user.data['aspect_memberships'].append( new_aspect_membership )
342 return new_aspect_membership
343
344 def remove(self, user_id, aspect_ids):
345 """Remove user from aspects of given ids.
346
347 :param user_id: user id
348 :type user_id: str
349 :param aspect_ids: list of aspect ids
350 :type aspect_ids: list
351 """
352 for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id)
353
354 def get(self, set='', page=0):
355 """Returns list of user contacts.
356 Contact is a User() who is in one or more of user's
357 aspects.
358
359 By default, it will return list of users who are in
360 user's aspects.
361
362 If `set` is `all` it will also include users who only share
363 with logged user and are not in his/hers aspects.
364
365 If `set` is `only_sharing` it will return users who are only
366 sharing with logged user and ARE NOT in his/hers aspects.
367
368 # On "All contacts" button diaspora
369 on the time of testing this I had 20 contacts and 10 that
370 where only sharing with me. So 30 in total.
371
372 --> GET /contacts?set=all HTTP/1.1
373 <-- HTTP/1.1 200 OK
374 returned 25 contacts (5 only sharing with me)
375
376 --> GET /contacts.json?page=1&set=all&_=1524410225376 HTTP/1.1
377 <-- HTTP/1.1 200 OK
378 returned the same list as before.
379
380 --> GET /contacts.json?page=2&set=all&_=1524410225377 HTTP/1.1
381 <-- HTTP/1.1 200 OK
382 returned the other 5 that where only sharing with me.
383
384 --> GET /contacts.json?page=3&set=all&_=1524410225378 HTTP/1.1
385 <-- HTTP/1.1 200 OK
386 returned empty list.
387
388 It appears that /contacts?set=all returns a maximum of 25
389 contacts.
390
391 So if /contacts?set=all returns 25 contacts then request next
392 page until page returns a list with less then 25. I don't see a
393 reason why we should request page=1 'cause the previous request
394 will be the same. So begin with page=2 if /contacts?set=all
395 returns 25.
396
397 :param set: if passed could be 'all' or 'only_sharing'
398 :type set: str
f54aff84
C
399 :param page: page number to get, default 0.
400 :type page: int
d95ff94a
C
401 """
402 params = {}
403 if set:
404 params['set'] = set
405 params['_'] = int(time.mktime(time.gmtime()))
406 if page: params['page'] = page
407
408 request = self._connection.get('contacts.json', params=params)
409 if request.status_code != 200:
410 raise Exception('status code {0}: cannot get contacts'.format(request.status_code))
411
412 json = request.json()
413 users = [User.parse(self._connection, each) for each in json]
414 if len(json) == 25:
415 if not page: page = 1
416 users += self.get(set=set, page=page+1)
417 return users