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