From: CYBERDEViLNL Date: Fri, 26 Oct 2018 17:05:03 +0000 (+0200) Subject: Change indentation from spaces to tabs as discussed here https://github.com/marekjm... X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=d95ff94a76bbeeaee6fe63f5480c63c2d1d18b8f;p=diaspy.git Change indentation from spaces to tabs as discussed here https://github.com/marekjm/diaspy/issues/38 --- diff --git a/diaspy/connection.py b/diaspy/connection.py index 17d92d9..9559706 100644 --- a/diaspy/connection.py +++ b/diaspy/connection.py @@ -18,238 +18,238 @@ DEBUG = True class Connection(): - """Object representing connection with the pod. - """ - _token_regex = re.compile(r'name="csrf-token"\s+content="(.*?)"') - _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})') - # this is for older version of D* - _token_regex_2 = re.compile(r'content="(.*?)"\s+name="csrf-token') - _userinfo_regex_2 = re.compile(r'gon.user=({.*?});gon.') - _verify_SSL = True - - def __init__(self, pod, username, password, schema='https'): - """ - :param pod: The complete url of the diaspora pod to use. - :type pod: str - :param username: The username used to log in. - :type username: str - :param password: The password used to log in. - :type password: str - """ - self.pod = pod - self._session = requests.Session() - self._login_data = {'user[remember_me]': 1, 'utf8': '✓'} - self._userdata = {} - self._token = '' - self._diaspora_session = '' - self._cookies = self._fetchcookies() - self._fetch_token_from = 'stream' - try: - #self._setlogin(username, password) - self._login_data = {'user[username]': username, - 'user[password]': password, - 'authenticity_token': self._fetchtoken()} - success = True - except requests.exceptions.MissingSchema: - self.pod = '{0}://{1}'.format(schema, self.pod) - warnings.warn('schema was missing') - success = False - finally: - pass - try: - if not success: - self._login_data = {'user[username]': username, - 'user[password]': password, - 'authenticity_token': self._fetchtoken()} - except Exception as e: - raise errors.LoginError('cannot create login data (caused by: {0})'.format(e)) - - def _fetchcookies(self): - request = self.get('stream') - return request.cookies - - def __repr__(self): - """Returns token string. - It will be easier to change backend if programs will just use: - repr(connection) - instead of calling a specified method. - """ - return self._fetchtoken() - - def get(self, string, headers={}, params={}, direct=False, **kwargs): - """This method gets data from session. - Performs additional checks if needed. - - Example: - To obtain 'foo' from pod one should call `get('foo')`. - - :param string: URL to get without the pod's URL and slash eg. 'stream'. - :type string: str - :param direct: if passed as True it will not be expanded - :type direct: bool - """ - if not direct: url = '{0}/{1}'.format(self.pod, string) - else: url = string - return self._session.get(url, params=params, headers=headers, verify=self._verify_SSL, **kwargs) - - def tokenFrom(self, location): - """Sets location for the *next* fetch of CSRF token. - Intended to be used for oneliners like this one: - - connection.tokenFrom('somewhere').delete(...) - - where the token comes from "somewhere" instead of the - default stream page. - """ - self._fetch_token_from = location - return self - - def post(self, string, data, headers={}, params={}, **kwargs): - """This method posts data to session. - Performs additional checks if needed. - - Example: - To post to 'foo' one should call `post('foo', data={})`. - - :param string: URL to post without the pod's URL and slash eg. 'status_messages'. - :type string: str - :param data: Data to post. - :param headers: Headers (optional). - :type headers: dict - :param params: Parameters (optional). - :type params: dict - """ - string = '{0}/{1}'.format(self.pod, string) - if 'X-CSRF-Token' not in headers: - headers['X-CSRF-Token'] = self.get_token() - request = self._session.post(string, data, headers=headers, params=params, verify=self._verify_SSL, **kwargs) - return request - - def put(self, string, data=None, headers={}, params={}, **kwargs): - """This method PUTs to session. - """ - string = '{0}/{1}'.format(self.pod, string) - if 'X-CSRF-Token' not in headers: - headers['X-CSRF-Token'] = self.get_token() - if data is not None: request = self._session.put(string, data, headers=headers, params=params, **kwargs) - else: request = self._session.put(string, headers=headers, params=params, verify=self._verify_SSL, **kwargs) - return request - - def delete(self, string, data = None, headers={}, **kwargs): - """This method lets you send delete request to session. - Performs additional checks if needed. - - :param string: URL to use. - :type string: str - :param data: Data to use. - :param headers: Headers to use (optional). - :type headers: dict - """ - string = '{0}/{1}'.format(self.pod, string) - if 'X-CSRF-Token' not in headers: - headers['X-CSRF-Token'] = self.get_token() - request = self._session.delete(string, data=data, headers=headers, verify=self._verify_SSL, **kwargs) - return request - - def _setlogin(self, username, password): - """This function is used to set data for login. - - .. note:: - It should be called before _login() function. - """ - self._login_data = {'user[username]': username, - 'user[password]': password, - 'authenticity_token': self._fetchtoken()} - - def _login(self): - """Handles actual login request. - Raises LoginError if login failed. - """ - request = self.post('users/sign_in', - data=self._login_data, - allow_redirects=False) - if request.status_code != 302: - raise errors.LoginError('{0}: login failed'.format(request.status_code)) - - def login(self, remember_me=1): - """This function is used to log in to a pod. - Will raise LoginError if password or username was not specified. - """ - if not self._login_data['user[username]'] or not self._login_data['user[password]']: - raise errors.LoginError('username and/or password is not specified') - self._login_data['user[remember_me]'] = remember_me - status = self._login() - self._login_data = {} - return self - - def logout(self): - """Logs out from a pod. - When logged out you can't do anything. - """ - self.get('users/sign_out') - self.token = '' - - def podswitch(self, pod, username, password): - """Switches pod from current to another one. - """ - self.pod = pod - self._setlogin(username, password) - self._login() - - def _fetchtoken(self): - """This method tries to get token string needed for authentication on D*. - - :returns: token string - """ - request = self.get(self._fetch_token_from) - token = self._token_regex.search(request.text) - if token is None: token = self._token_regex_2.search(request.text) - if token is not None: token = token.group(1) - else: raise errors.TokenError('could not find valid CSRF token') - self._token = token - self._fetch_token_from = 'stream' - return token - - def get_token(self, fetch=True): - """This function returns a token needed for authentication in most cases. - **Notice:** using repr() is recommended method for getting token. - - Each time it is run a _fetchtoken() is called and refreshed token is stored. - - It is more safe to use than _fetchtoken(). - By setting new you can request new token or decide to get stored one. - If no token is stored new one will be fetched anyway. - - :returns: string -- token used to authenticate - """ - try: - if fetch or not self._token: self._fetchtoken() - except requests.exceptions.ConnectionError as e: - warnings.warn('{0} was cought: reusing old token'.format(e)) - finally: - if not self._token: raise errors.TokenError('cannot obtain token and no previous token found for reuse') - return self._token - - def getSessionToken(self): - """Returns session token string (_diaspora_session). - """ - return self._diaspora_session - - def userdata(self): - return self._userdata - - def getUserData(self): - """Returns user data. - """ - request = self.get('bookmarklet') - userdata = self._userinfo_regex.search(request.text) - if userdata is None: userdata = self._userinfo_regex_2.search(request.text) - if userdata is None: raise errors.DiaspyError('cannot find user data') - userdata = userdata.group(1) - self._userdata = json.loads(userdata) - return self._userdata - - def set_verify_SSL(self, verify): - """Sets whether there should be an error if a SSL-Certificate could not be verified. - """ - self._verify_SSL = verify + """Object representing connection with the pod. + """ + _token_regex = re.compile(r'name="csrf-token"\s+content="(.*?)"') + _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})') + # this is for older version of D* + _token_regex_2 = re.compile(r'content="(.*?)"\s+name="csrf-token') + _userinfo_regex_2 = re.compile(r'gon.user=({.*?});gon.') + _verify_SSL = True + + def __init__(self, pod, username, password, schema='https'): + """ + :param pod: The complete url of the diaspora pod to use. + :type pod: str + :param username: The username used to log in. + :type username: str + :param password: The password used to log in. + :type password: str + """ + self.pod = pod + self._session = requests.Session() + self._login_data = {'user[remember_me]': 1, 'utf8': '✓'} + self._userdata = {} + self._token = '' + self._diaspora_session = '' + self._cookies = self._fetchcookies() + self._fetch_token_from = 'stream' + try: + #self._setlogin(username, password) + self._login_data = {'user[username]': username, + 'user[password]': password, + 'authenticity_token': self._fetchtoken()} + success = True + except requests.exceptions.MissingSchema: + self.pod = '{0}://{1}'.format(schema, self.pod) + warnings.warn('schema was missing') + success = False + finally: + pass + try: + if not success: + self._login_data = {'user[username]': username, + 'user[password]': password, + 'authenticity_token': self._fetchtoken()} + except Exception as e: + raise errors.LoginError('cannot create login data (caused by: {0})'.format(e)) + + def _fetchcookies(self): + request = self.get('stream') + return request.cookies + + def __repr__(self): + """Returns token string. + It will be easier to change backend if programs will just use: + repr(connection) + instead of calling a specified method. + """ + return self._fetchtoken() + + def get(self, string, headers={}, params={}, direct=False, **kwargs): + """This method gets data from session. + Performs additional checks if needed. + + Example: + To obtain 'foo' from pod one should call `get('foo')`. + + :param string: URL to get without the pod's URL and slash eg. 'stream'. + :type string: str + :param direct: if passed as True it will not be expanded + :type direct: bool + """ + if not direct: url = '{0}/{1}'.format(self.pod, string) + else: url = string + return self._session.get(url, params=params, headers=headers, verify=self._verify_SSL, **kwargs) + + def tokenFrom(self, location): + """Sets location for the *next* fetch of CSRF token. + Intended to be used for oneliners like this one: + + connection.tokenFrom('somewhere').delete(...) + + where the token comes from "somewhere" instead of the + default stream page. + """ + self._fetch_token_from = location + return self + + def post(self, string, data, headers={}, params={}, **kwargs): + """This method posts data to session. + Performs additional checks if needed. + + Example: + To post to 'foo' one should call `post('foo', data={})`. + + :param string: URL to post without the pod's URL and slash eg. 'status_messages'. + :type string: str + :param data: Data to post. + :param headers: Headers (optional). + :type headers: dict + :param params: Parameters (optional). + :type params: dict + """ + string = '{0}/{1}'.format(self.pod, string) + if 'X-CSRF-Token' not in headers: + headers['X-CSRF-Token'] = self.get_token() + request = self._session.post(string, data, headers=headers, params=params, verify=self._verify_SSL, **kwargs) + return request + + def put(self, string, data=None, headers={}, params={}, **kwargs): + """This method PUTs to session. + """ + string = '{0}/{1}'.format(self.pod, string) + if 'X-CSRF-Token' not in headers: + headers['X-CSRF-Token'] = self.get_token() + if data is not None: request = self._session.put(string, data, headers=headers, params=params, **kwargs) + else: request = self._session.put(string, headers=headers, params=params, verify=self._verify_SSL, **kwargs) + return request + + def delete(self, string, data = None, headers={}, **kwargs): + """This method lets you send delete request to session. + Performs additional checks if needed. + + :param string: URL to use. + :type string: str + :param data: Data to use. + :param headers: Headers to use (optional). + :type headers: dict + """ + string = '{0}/{1}'.format(self.pod, string) + if 'X-CSRF-Token' not in headers: + headers['X-CSRF-Token'] = self.get_token() + request = self._session.delete(string, data=data, headers=headers, verify=self._verify_SSL, **kwargs) + return request + + def _setlogin(self, username, password): + """This function is used to set data for login. + + .. note:: + It should be called before _login() function. + """ + self._login_data = {'user[username]': username, + 'user[password]': password, + 'authenticity_token': self._fetchtoken()} + + def _login(self): + """Handles actual login request. + Raises LoginError if login failed. + """ + request = self.post('users/sign_in', + data=self._login_data, + allow_redirects=False) + if request.status_code != 302: + raise errors.LoginError('{0}: login failed'.format(request.status_code)) + + def login(self, remember_me=1): + """This function is used to log in to a pod. + Will raise LoginError if password or username was not specified. + """ + if not self._login_data['user[username]'] or not self._login_data['user[password]']: + raise errors.LoginError('username and/or password is not specified') + self._login_data['user[remember_me]'] = remember_me + status = self._login() + self._login_data = {} + return self + + def logout(self): + """Logs out from a pod. + When logged out you can't do anything. + """ + self.get('users/sign_out') + self.token = '' + + def podswitch(self, pod, username, password): + """Switches pod from current to another one. + """ + self.pod = pod + self._setlogin(username, password) + self._login() + + def _fetchtoken(self): + """This method tries to get token string needed for authentication on D*. + + :returns: token string + """ + request = self.get(self._fetch_token_from) + token = self._token_regex.search(request.text) + if token is None: token = self._token_regex_2.search(request.text) + if token is not None: token = token.group(1) + else: raise errors.TokenError('could not find valid CSRF token') + self._token = token + self._fetch_token_from = 'stream' + return token + + def get_token(self, fetch=True): + """This function returns a token needed for authentication in most cases. + **Notice:** using repr() is recommended method for getting token. + + Each time it is run a _fetchtoken() is called and refreshed token is stored. + + It is more safe to use than _fetchtoken(). + By setting new you can request new token or decide to get stored one. + If no token is stored new one will be fetched anyway. + + :returns: string -- token used to authenticate + """ + try: + if fetch or not self._token: self._fetchtoken() + except requests.exceptions.ConnectionError as e: + warnings.warn('{0} was cought: reusing old token'.format(e)) + finally: + if not self._token: raise errors.TokenError('cannot obtain token and no previous token found for reuse') + return self._token + + def getSessionToken(self): + """Returns session token string (_diaspora_session). + """ + return self._diaspora_session + + def userdata(self): + return self._userdata + + def getUserData(self): + """Returns user data. + """ + request = self.get('bookmarklet') + userdata = self._userinfo_regex.search(request.text) + if userdata is None: userdata = self._userinfo_regex_2.search(request.text) + if userdata is None: raise errors.DiaspyError('cannot find user data') + userdata = userdata.group(1) + self._userdata = json.loads(userdata) + return self._userdata + + def set_verify_SSL(self, verify): + """Sets whether there should be an error if a SSL-Certificate could not be verified. + """ + self._verify_SSL = verify diff --git a/diaspy/conversations.py b/diaspy/conversations.py index cd141ec..8a59ceb 100644 --- a/diaspy/conversations.py +++ b/diaspy/conversations.py @@ -5,28 +5,28 @@ from diaspy import errors, models class Mailbox(): - """Object implementing diaspora* mailbox. - """ - def __init__(self, connection, fetch=True): - self._connection = connection - self._mailbox = [] - if fetch: self._fetch() - - def __len__(self): - return len(self._mailbox) - - def __iter__(self): - return iter(self._mailbox) - - def __getitem__(self, n): - return self._mailbox[n] - - def _fetch(self): - """This method will fetch messages from user's mailbox. - """ - request = self._connection.get('conversations.json') - - if request.status_code != 200: - raise errors.DiaspyError('wrong status code: {0}'.format(request.status_code)) - mailbox = request.json() - self._mailbox = [models.Conversation(self._connection, c['conversation']['id']) for c in mailbox] + """Object implementing diaspora* mailbox. + """ + def __init__(self, connection, fetch=True): + self._connection = connection + self._mailbox = [] + if fetch: self._fetch() + + def __len__(self): + return len(self._mailbox) + + def __iter__(self): + return iter(self._mailbox) + + def __getitem__(self, n): + return self._mailbox[n] + + def _fetch(self): + """This method will fetch messages from user's mailbox. + """ + request = self._connection.get('conversations.json') + + if request.status_code != 200: + raise errors.DiaspyError('wrong status code: {0}'.format(request.status_code)) + mailbox = request.json() + self._mailbox = [models.Conversation(self._connection, c['conversation']['id']) for c in mailbox] diff --git a/diaspy/errors.py b/diaspy/errors.py index d28aa73..d5d3f0e 100644 --- a/diaspy/errors.py +++ b/diaspy/errors.py @@ -7,158 +7,158 @@ raised by API implementations but are specific to this particular implementation If your program should catch all exceptions raised by diaspy and does not need to handle them specifically you can use following code: - # this line imports all errors - from diaspy.errors import * - - try: - # your code... - except DiaspyError as e: - # your error handling code... - finally: - # closing code... + # this line imports all errors + from diaspy.errors import * + + try: + # your code... + except DiaspyError as e: + # your error handling code... + finally: + # closing code... """ import warnings class DiaspyError(Exception): - """Base exception for all errors - raised by diaspy. - """ - pass + """Base exception for all errors + raised by diaspy. + """ + pass class LoginError(DiaspyError): - """Exception raised when something - bad happens while performing actions - related to logging in. - """ - pass + """Exception raised when something + bad happens while performing actions + related to logging in. + """ + pass class TokenError(DiaspyError): - pass + pass class CSRFProtectionKickedIn(TokenError): - pass + pass class DataError(DiaspyError): - pass + pass class InvalidDataError(DataError): - pass + pass class KeyMissingFromFetchedData(InvalidDataError): - pass + pass class UserError(DiaspyError): - """Exception raised when something related to users goes wrong. - """ - pass + """Exception raised when something related to users goes wrong. + """ + pass class InvalidHandleError(DiaspyError): - """Raised when invalid handle is found. - """ - pass + """Raised when invalid handle is found. + """ + pass class SearchError(DiaspyError): - """Exception raised when something related to search goes wrong. - """ - pass + """Exception raised when something related to search goes wrong. + """ + pass class ConversationError(DiaspyError): - """Exception raised when something related to conversations goes wrong. - """ - pass + """Exception raised when something related to conversations goes wrong. + """ + pass class AspectError(DiaspyError): - """Exception raised when something related to aspects goes wrong. - """ - pass + """Exception raised when something related to aspects goes wrong. + """ + pass class UserIsNotMemberOfAspect(AspectError): - pass + pass class PostError(DiaspyError): - """Exception raised when something related to posts goes wrong. - """ - pass + """Exception raised when something related to posts goes wrong. + """ + pass class StreamError(DiaspyError): - """Exception raised when something related to streams goes wrong. - """ - pass + """Exception raised when something related to streams goes wrong. + """ + pass class SettingsError(DiaspyError): - """Exception raised when something related to settings goes wrong. - """ - pass + """Exception raised when something related to settings goes wrong. + """ + pass class SearchError(DiaspyError): - """Exception raised when something related to searching goes wrong. - """ - pass + """Exception raised when something related to searching goes wrong. + """ + pass class TagError(DiaspyError): - """Exception raised when something related to settings goes wrong. - """ - pass + """Exception raised when something related to settings goes wrong. + """ + pass def react(r, message='', accepted=[200, 201, 202, 203, 204, 205, 206], exception=DiaspyError): - """This method tries to decide how to react - to a response code passed to it. If it's an - error code it will raise an exception (it will - call `throw()` method. - - If response code is not accepted AND cannot - be matched to any exception, generic exception - (DiaspyError) is raised (provided that `exception` - param was left untouched). - - By default `accepted` param contains all HTTP - success codes. - - User can force type of exception to raise by passing - `exception` param. - - :param r: response code - :type r: int - :param message: message for the exception - :type message: str - :param accepted: list of accepted error codes - :type accepted: list - :param exception: preferred exception to raise - :type exception: valid exception type (default: DiaspyError) - """ - warnings.warn(DeprecationWarning) - if r in accepted: e = None - else: e = DiaspyError - - if e is not None: e = exception - throw(e, message=message) + """This method tries to decide how to react + to a response code passed to it. If it's an + error code it will raise an exception (it will + call `throw()` method. + + If response code is not accepted AND cannot + be matched to any exception, generic exception + (DiaspyError) is raised (provided that `exception` + param was left untouched). + + By default `accepted` param contains all HTTP + success codes. + + User can force type of exception to raise by passing + `exception` param. + + :param r: response code + :type r: int + :param message: message for the exception + :type message: str + :param accepted: list of accepted error codes + :type accepted: list + :param exception: preferred exception to raise + :type exception: valid exception type (default: DiaspyError) + """ + warnings.warn(DeprecationWarning) + if r in accepted: e = None + else: e = DiaspyError + + if e is not None: e = exception + throw(e, message=message) def throw(e, message=''): - """This function throws an error with given message. - If None is passed as `e` throw() will not raise - anything. - - :param e: exception to throw - :type e: any valid exception type or None - :param message: message for exception - :type message: str - """ - warnings.warn(DeprecationWarning) - if e is None: pass - else: raise e(message) + """This function throws an error with given message. + If None is passed as `e` throw() will not raise + anything. + + :param e: exception to throw + :type e: any valid exception type or None + :param message: message for exception + :type message: str + """ + warnings.warn(DeprecationWarning) + if e is None: pass + else: raise e(message) diff --git a/diaspy/models.py b/diaspy/models.py index d6281dd..fd2a643 100644 --- a/diaspy/models.py +++ b/diaspy/models.py @@ -12,607 +12,603 @@ import re from diaspy import errors class Aspect(): - """This class represents an aspect. - - Class can be initialized by passing either an id and/or name as - parameters. - If both are missing, an exception will be raised. - """ - def __init__(self, connection, id, name=None): - self._connection = connection - self.id, self.name = id, name - self._cached = [] - - def getUsers(self, fetch = True): - """Returns list of GUIDs of users who are listed in this aspect. - """ - if fetch: - request = self._connection.get('contacts.json?a_id={}'.format(self.id)) - self._cached = request.json() - return self._cached - - def removeAspect(self): - """ - --> POST /aspects/{id} HTTP/1.1 - --> _method=delete&authenticity_token={token} - - <-- HTTP/1.1 302 Found - - TODO: status_codes - - Removes whole aspect. - :returns: None - """ - request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format(self.id)) - - if request.status_code != 302: - raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) - - def addUser(self, user_id): - """Add user to current aspect. - - :param user_id: user to add to aspect - :type user_id: int - :returns: JSON from request - - --> POST /aspect_memberships HTTP/1.1 - --> Accept: application/json, text/javascript, */*; q=0.01 - --> Content-Type: application/json; charset=UTF-8 - - --> {"aspect_id":123,"person_id":123} - - <-- HTTP/1.1 200 OK - """ - data = { - 'aspect_id': self.id, - 'person_id': user_id - } - headers = {'content-type': 'application/json', - 'accept': 'application/json' - } - - request = self._connection.tokenFrom('contacts').post('aspect_memberships', data=json.dumps(data), headers=headers) - - if request.status_code == 400: - raise errors.AspectError('duplicate record, user already exists in aspect: {0}'.format(request.status_code)) - elif request.status_code == 404: - raise errors.AspectError('user not found from this pod: {0}'.format(request.status_code)) - elif request.status_code != 200: - raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) - - response = None - try: - response = request.json() - except json.decoder.JSONDecodeError: - """ Should be OK now, but I'll leave this commentary here - at first to see if anything comes up """ - # FIXME For some (?) reason removing users from aspects works, but - # adding them is a no-go and Diaspora* kicks us out with CSRF errors. - # Weird. - pass - - if response is None: - raise errors.CSRFProtectionKickedIn() - - # Now you should fetchguid(fetch_stream=False) on User to update aspect membership_id's - # Or update it locally with the response - return response - - def removeUser(self, user): - """Remove user from current aspect. - - :param user: user to remove from aspect - :type user: diaspy.people.User object - """ - membership_id = None - to_remove = None - for each in user.aspectMemberships(): - print(self.id, each) - if each.get('aspect', {}).get('id') == self.id: - membership_id = each.get('id') - to_remove = each - break # no need to continue - - if membership_id is None: - raise errors.UserIsNotMemberOfAspect(user, self) - - request = self._connection.delete('aspect_memberships/{0}'.format(membership_id)) - - if request.status_code == 404: - raise errors.AspectError('cannot remove user from aspect, probably tried too fast after adding: {0}'.format(request.status_code)) - - elif request.status_code != 200: - raise errors.AspectError('cannot remove user from aspect: {0}'.format(request.status_code)) - - if 'contact' in user.data: # User object - if to_remove: user.data['contact']['aspect_memberships'].remove( to_remove ) # remove local aspect membership_id - else: # User object from Contacts() - if to_remove: user.data['aspect_memberships'].remove( to_remove ) # remove local aspect membership_id - return request.json() + """This class represents an aspect. + + Class can be initialized by passing either an id and/or name as + parameters. + If both are missing, an exception will be raised. + """ + def __init__(self, connection, id, name=None): + self._connection = connection + self.id, self.name = id, name + self._cached = [] + + def getUsers(self, fetch = True): + """Returns list of GUIDs of users who are listed in this aspect. + """ + if fetch: + request = self._connection.get('contacts.json?a_id={}'.format(self.id)) + self._cached = request.json() + return self._cached + + def removeAspect(self): + """ + --> POST /aspects/{id} HTTP/1.1 + --> _method=delete&authenticity_token={token} + + <-- HTTP/1.1 302 Found + + TODO: status_codes + + Removes whole aspect. + :returns: None + """ + request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format(self.id)) + + if request.status_code != 302: + raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) + + def addUser(self, user_id): + """Add user to current aspect. + + :param user_id: user to add to aspect + :type user_id: int + :returns: JSON from request + + --> POST /aspect_memberships HTTP/1.1 + --> Accept: application/json, text/javascript, */*; q=0.01 + --> Content-Type: application/json; charset=UTF-8 + + --> {"aspect_id":123,"person_id":123} + + <-- HTTP/1.1 200 OK + """ + data = {'aspect_id': self.id, + 'person_id': user_id} + headers = {'content-type': 'application/json', + 'accept': 'application/json'} + request = self._connection.tokenFrom('contacts').post('aspect_memberships', data=json.dumps(data), headers=headers) + + if request.status_code == 400: + raise errors.AspectError('duplicate record, user already exists in aspect: {0}'.format(request.status_code)) + elif request.status_code == 404: + raise errors.AspectError('user not found from this pod: {0}'.format(request.status_code)) + elif request.status_code != 200: + raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) + + response = None + try: + response = request.json() + except json.decoder.JSONDecodeError: + """ Should be OK now, but I'll leave this commentary here + at first to see if anything comes up """ + # FIXME For some (?) reason removing users from aspects works, but + # adding them is a no-go and Diaspora* kicks us out with CSRF errors. + # Weird. + pass + + if response is None: + raise errors.CSRFProtectionKickedIn() + + # Now you should fetchguid(fetch_stream=False) on User to update aspect membership_id's + # Or update it locally with the response + return response + + def removeUser(self, user): + """Remove user from current aspect. + + :param user: user to remove from aspect + :type user: diaspy.people.User object + """ + membership_id = None + to_remove = None + for each in user.aspectMemberships(): + print(self.id, each) + if each.get('aspect', {}).get('id') == self.id: + membership_id = each.get('id') + to_remove = each + break # no need to continue + + if membership_id is None: + raise errors.UserIsNotMemberOfAspect(user, self) + + request = self._connection.delete('aspect_memberships/{0}'.format(membership_id)) + + if request.status_code == 404: + raise errors.AspectError('cannot remove user from aspect, probably tried too fast after adding: {0}'.format(request.status_code)) + + elif request.status_code != 200: + raise errors.AspectError('cannot remove user from aspect: {0}'.format(request.status_code)) + + if 'contact' in user.data: # User object + if to_remove: user.data['contact']['aspect_memberships'].remove( to_remove ) # remove local aspect membership_id + else: # User object from Contacts() + if to_remove: user.data['aspect_memberships'].remove( to_remove ) # remove local aspect membership_id + return request.json() class Notification(): - """This class represents single notification. - """ - _who_regexp = re.compile(r'/people/([0-9a-f]+)["\']{1} class=["\']{1}hovercardable') - _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') - _aboutid_regexp = re.compile(r'/posts/[0-9a-f]+') - _htmltag_regexp = re.compile('') - - def __init__(self, connection, data): - self._connection = connection - self.type = data['type'] - self._data = data[self.type] - self.id = self._data['id'] - self.unread = self._data['unread'] - - def __getitem__(self, key): - """Returns a key from notification data. - """ - return self._data[key] - - def __str__(self): - """Returns notification note. - """ - string = re.sub(self._htmltag_regexp, '', self._data['note_html']) - string = string.strip().split('\n')[0] - while ' ' in string: string = string.replace(' ', ' ') - return string - - def __repr__(self): - """Returns notification note with more details. - """ - return '{0}: {1}'.format(self.when(), str(self)) - - def about(self): - """Returns id of post about which the notification is informing OR: - If the id is None it means that it's about user so .who() is called. - """ - about = self._aboutid_regexp.search(self._data['note_html']) - if about is None: about = self.who() - else: about = int(about.group(0)[7:]) - return about - - def who(self): - """Returns list of guids of the users who caused you to get the notification. - """ - return [who for who in self._who_regexp.findall(self._data['note_html'])] - - def when(self): - """Returns UTC time as found in note_html. - """ - return self._data['created_at'] - - def mark(self, unread=False): - """Marks notification to read/unread. - Marks notification to read if `unread` is False. - Marks notification to unread if `unread` is True. - - :param unread: which state set for notification - :type unread: bool - """ - headers = {'x-csrf-token': repr(self._connection)} - params = {'set_unread': json.dumps(unread)} - self._connection.put('notifications/{0}'.format(self['id']), params=params, headers=headers) - self._data['unread'] = unread + """This class represents single notification. + """ + _who_regexp = re.compile(r'/people/([0-9a-f]+)["\']{1} class=["\']{1}hovercardable') + _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') + _aboutid_regexp = re.compile(r'/posts/[0-9a-f]+') + _htmltag_regexp = re.compile('') + + def __init__(self, connection, data): + self._connection = connection + self.type = data['type'] + self._data = data[self.type] + self.id = self._data['id'] + self.unread = self._data['unread'] + + def __getitem__(self, key): + """Returns a key from notification data. + """ + return self._data[key] + + def __str__(self): + """Returns notification note. + """ + string = re.sub(self._htmltag_regexp, '', self._data['note_html']) + string = string.strip().split('\n')[0] + while ' ' in string: string = string.replace(' ', ' ') + return string + + def __repr__(self): + """Returns notification note with more details. + """ + return '{0}: {1}'.format(self.when(), str(self)) + + def about(self): + """Returns id of post about which the notification is informing OR: + If the id is None it means that it's about user so .who() is called. + """ + about = self._aboutid_regexp.search(self._data['note_html']) + if about is None: about = self.who() + else: about = int(about.group(0)[7:]) + return about + + def who(self): + """Returns list of guids of the users who caused you to get the notification. + """ + return [who for who in self._who_regexp.findall(self._data['note_html'])] + + def when(self): + """Returns UTC time as found in note_html. + """ + return self._data['created_at'] + + def mark(self, unread=False): + """Marks notification to read/unread. + Marks notification to read if `unread` is False. + Marks notification to unread if `unread` is True. + + :param unread: which state set for notification + :type unread: bool + """ + headers = {'x-csrf-token': repr(self._connection)} + params = {'set_unread': json.dumps(unread)} + self._connection.put('notifications/{0}'.format(self['id']), params=params, headers=headers) + self._data['unread'] = unread class Conversation(): - """This class represents a conversation. - - .. note:: - Remember that you need to have access to the conversation. - """ - def __init__(self, connection, id, fetch=True): - """ - :param conv_id: id of the post and not the guid! - :type conv_id: str - :param connection: connection object used to authenticate - :type connection: connection.Connection - """ - self._connection = connection - self.id = id - self._data = {} - if fetch: self._fetch() - - def _fetch(self): - """Fetches JSON data representing conversation. - """ - request = self._connection.get('conversations/{}.json'.format(self.id)) - if request.status_code == 200: - self._data = request.json()['conversation'] - else: - raise errors.ConversationError('cannot download conversation data: {0}'.format(request.status_code)) - - def answer(self, text): - """Answer that conversation - - :param text: text to answer. - :type text: str - """ - data = {'message[text]': text, - 'utf8': '✓', - 'authenticity_token': repr(self._connection)} - - request = self._connection.post('conversations/{}/messages'.format(self.id), - data=data, - headers={'accept': 'application/json'}) - if request.status_code != 200: - raise errors.ConversationError('{0}: Answer could not be posted.' - .format(request.status_code)) - return request.json() - - def delete(self): - """Delete this conversation. - Has to be implemented. - """ - data = {'authenticity_token': repr(self._connection)} - - request = self._connection.delete('conversations/{0}/visibility/' - .format(self.id), - data=data, - headers={'accept': 'application/json'}) - - if request.status_code != 404: - raise errors.ConversationError('{0}: Conversation could not be deleted.' - .format(request.status_code)) - - def get_subject(self): - """Returns the subject of this conversation - """ - return self._data['subject'] + """This class represents a conversation. + + .. note:: + Remember that you need to have access to the conversation. + """ + def __init__(self, connection, id, fetch=True): + """ + :param conv_id: id of the post and not the guid! + :type conv_id: str + :param connection: connection object used to authenticate + :type connection: connection.Connection + """ + self._connection = connection + self.id = id + self._data = {} + if fetch: self._fetch() + + def _fetch(self): + """Fetches JSON data representing conversation. + """ + request = self._connection.get('conversations/{}.json'.format(self.id)) + if request.status_code == 200: + self._data = request.json()['conversation'] + else: + raise errors.ConversationError('cannot download conversation data: {0}'.format(request.status_code)) + + def answer(self, text): + """Answer that conversation + + :param text: text to answer. + :type text: str + """ + data = {'message[text]': text, + 'utf8': '✓', + 'authenticity_token': repr(self._connection)} + + request = self._connection.post('conversations/{}/messages'.format(self.id), + data=data, + headers={'accept': 'application/json'}) + if request.status_code != 200: + raise errors.ConversationError('{0}: Answer could not be posted.' + .format(request.status_code)) + return request.json() + + def delete(self): + """Delete this conversation. + Has to be implemented. + """ + data = {'authenticity_token': repr(self._connection)} + + request = self._connection.delete('conversations/{0}/visibility/' + .format(self.id), + data=data, + headers={'accept': 'application/json'}) + + if request.status_code != 404: + raise errors.ConversationError('{0}: Conversation could not be deleted.' + .format(request.status_code)) + + def get_subject(self): + """Returns the subject of this conversation + """ + return self._data['subject'] class Comment(): - """Represents comment on post. - - Does not require Connection() object. Note that you should not manually - create `Comment()` objects -- they are designed to be created automatically - by `Post()` objects. - """ - def __init__(self, data): - self._data = data - self.id = data['id'] - self.guid = data['guid'] - - def __str__(self): - """Returns comment's text. - """ - return self._data['text'] - - def __repr__(self): - """Returns comments text and author. - Format: AUTHOR (AUTHOR'S GUID): COMMENT - """ - return '{0} ({1}): {2}'.format(self.author(), self.author('guid'), str(self)) - - def when(self): - """Returns time when the comment had been created. - """ - return self._data['created_at'] - - def author(self, key='name'): - """Returns author of the comment. - """ - return self._data['author'][key] + """Represents comment on post. + + Does not require Connection() object. Note that you should not manually + create `Comment()` objects -- they are designed to be created automatically + by `Post()` objects. + """ + def __init__(self, data): + self._data = data + self.id = data['id'] + self.guid = data['guid'] + + def __str__(self): + """Returns comment's text. + """ + return self._data['text'] + + def __repr__(self): + """Returns comments text and author. + Format: AUTHOR (AUTHOR'S GUID): COMMENT + """ + return '{0} ({1}): {2}'.format(self.author(), self.author('guid'), str(self)) + + def when(self): + """Returns time when the comment had been created. + """ + return self._data['created_at'] + + def author(self, key='name'): + """Returns author of the comment. + """ + return self._data['author'][key] class Comments(): - def __init__(self, comments=None): - self._comments = comments - - def __iter__(self): - if self._comments: - for comment in self._comments: - yield comment - - def __len__(self): - if self._comments: - return len(self._comments) - - def __getitem__(self, index): - if self._comments: - return self._comments[index] - - def __bool__(self): - if self._comments: - return True - return False - - def ids(self): - return [c.id for c in self._comments] - - def add(self, comment): - """ Expects comment object - TODO self._comments is None sometimes, have to look into it.""" - if comment and self._comments: - self._comments.append(comment) - - def set(self, comments): - """Sets comments wich already have a Comment obj""" - if comments: - self._comments = comments - - def set_json(self, json_comments): - """Sets comments for this post from post data.""" - if json_comments: - self._comments = [Comment(c) for c in json_comments] + def __init__(self, comments=None): + self._comments = comments + + def __iter__(self): + if self._comments: + for comment in self._comments: + yield comment + + def __len__(self): + if self._comments: + return len(self._comments) + + def __getitem__(self, index): + if self._comments: + return self._comments[index] + + def __bool__(self): + if self._comments: + return True + return False + + def ids(self): + return [c.id for c in self._comments] + + def add(self, comment): + """ Expects comment object + TODO self._comments is None sometimes, have to look into it.""" + if comment and self._comments: + self._comments.append(comment) + + def set(self, comments): + """Sets comments wich already have a Comment obj""" + if comments: + self._comments = comments + + def set_json(self, json_comments): + """Sets comments for this post from post data.""" + if json_comments: + self._comments = [Comment(c) for c in json_comments] class Post(): - """This class represents a post. - - .. note:: - Remember that you need to have access to the post. - """ - def __init__(self, connection, id=0, guid='', fetch=True, comments=True, post_data=None): - """ - :param id: id of the post (GUID is recommended) - :type id: int - :param guid: GUID of the post - :type guid: str - :param connection: connection object used to authenticate - :type connection: connection.Connection - :param fetch: defines whether to fetch post's data or not - :type fetch: bool - :param comments: defines whether to fetch post's comments or not (if True also data will be fetched) - :type comments: bool - :param post_data: contains post data so no need to fetch the post if this is set, until you want to update post data - :type: json - """ - if not (guid or id): raise TypeError('neither guid nor id was provided') - self._connection = connection - self.id = id - self.guid = guid - self._data = {} - self.comments = Comments() - if post_data: - self._data = post_data - - if fetch: self._fetchdata() - if comments: - if not self._data: self._fetchdata() - self._fetchcomments() - else: - if not self._data: self._fetchdata() - self.comments.set_json( self['interactions']['comments'] ) - - def __repr__(self): - """Returns string containing more information then str(). - """ - return '{0} ({1}): {2}'.format(self._data['author']['name'], self._data['author']['guid'], self._data['text']) - - def __str__(self): - """Returns text of a post. - """ - return self._data['text'] - - def __getitem__(self, key): - """FIXME This is deprecated, use diaspy.models.Post.data() instead to access - data of Post objects. - """ - return self._data[key] - - def __dict__(self): - """Returns dictionary of posts data. - FIXME This is deprecated, use diaspy.models.Post.data() instead. - """ - return self._data - - def _fetchdata(self): - """This function retrieves data of the post. - - :returns: guid of post whose data was fetched - """ - if self.id: id = self.id - if self.guid: id = self.guid - request = self._connection.get('posts/{0}.json'.format(id)) - if request.status_code != 200: - raise errors.PostError('{0}: could not fetch data for post: {1}'.format(request.status_code, id)) - elif request: - self._data = request.json() - return self['guid'] - - def _fetchcomments(self): - """Retreives comments for this post. - Retrieving comments via GUID will result in 404 error. - DIASPORA* does not supply comments through /posts/:guid/ endpoint. - """ - id = self._data['id'] - if self['interactions']['comments_count']: - request = self._connection.get('posts/{0}/comments.json'.format(id)) - if request.status_code != 200: - raise errors.PostError('{0}: could not fetch comments for post: {1}'.format(request.status_code, id)) - else: - self.comments.set([Comment(c) for c in request.json()]) - - def update(self): - """Updates post data. - FIXME This is deprecated. - """ - print('diaspy.models.Post.update() is deprecated. Use diaspy.models.Post.update() instead.') - self._fetchdata() - self._fetchcomments() - - def fetch(self, comments = False): - """Fetches post data. - Use this function instead of diaspy.models.Post.update(). - """ - self._fetchdata() - if comments: - self._fetchcomments() - return self - - def data(self, data = None): - if data is not None: - self._data = data - return self._data - - def like(self): - """This function likes a post. - It abstracts the 'Like' functionality. - - :returns: dict -- json formatted like object. - """ - data = {'authenticity_token': repr(self._connection)} - - request = self._connection.post('posts/{0}/likes'.format(self.id), - data=data, - headers={'accept': 'application/json'}) - - if request.status_code != 201: - raise errors.PostError('{0}: Post could not be liked.' - .format(request.status_code)) - - likes_json = request.json() - if likes_json: - self._data['interactions']['likes'] = [likes_json] - return likes_json - - def reshare(self): - """This function reshares a post - """ - data = {'root_guid': self._data['guid'], - 'authenticity_token': repr(self._connection)} - - request = self._connection.post('reshares', - data=data, - headers={'accept': 'application/json'}) - if request.status_code != 201: - raise Exception('{0}: Post could not be reshared'.format(request.status_code)) - return request.json() - - def comment(self, text): - """This function comments on a post - - :param text: text to comment. - :type text: str - """ - data = {'text': text, - 'authenticity_token': repr(self._connection)} - request = self._connection.post('posts/{0}/comments'.format(self.id), - data=data, - headers={'accept': 'application/json'}) - - if request.status_code != 201: - raise Exception('{0}: Comment could not be posted.' - .format(request.status_code)) - return Comment(request.json()) - - def vote_poll(self, poll_answer_id): - """This function votes on a post's poll - - :param poll_answer_id: id to poll vote. - :type poll_answer_id: int - """ - poll_id = self._data['poll']['poll_id'] - data = {'poll_answer_id': poll_answer_id, - 'poll_id': poll_id, - 'post_id': self.id, - 'authenticity_token': repr(self._connection)} - request = self._connection.post('posts/{0}/poll_participations'.format(self.id), - data=data, - headers={'accept': 'application/json'}) - if request.status_code != 201: - raise Exception('{0}: Vote on poll failed.' - .format(request.status_code)) - return request.json() - - def hide(self): - """ - -> PUT /share_visibilities/42 HTTP/1.1 - post_id=123 - <- HTTP/1.1 200 OK - """ - headers = {'x-csrf-token': repr(self._connection)} - params = {'post_id': json.dumps(self.id)} - request = self._connection.put('share_visibilities/42', params=params, headers=headers) - if request.status_code != 200: - raise Exception('{0}: Failed to hide post.' - .format(request.status_code)) - - def mute(self): - """ - -> POST /blocks HTTP/1.1 - {"block":{"person_id":123}} - <- HTTP/1.1 204 No Content - """ - headers = {'content-type':'application/json', 'x-csrf-token': repr(self._connection)} - data = json.dumps({ 'block': { 'person_id' : self._data['author']['id'] } }) - request = self._connection.post('blocks', data=data, headers=headers) - if request.status_code != 204: - raise Exception('{0}: Failed to block person' - .format(request.status_code)) - - def subscribe(self): - """ - -> POST /posts/123/participation HTTP/1.1 - <- HTTP/1.1 201 Created - """ - headers = {'x-csrf-token': repr(self._connection)} - data = {} - request = self._connection.post('posts/{}/participation' - .format( self.id ), data=data, headers=headers) - if request.status_code != 201: - raise Exception('{0}: Failed to subscribe to post' - .format(request.status_code)) - - def unsubscribe(self): - """ - -> POST /posts/123/participation HTTP/1.1 - _method=delete - <- HTTP/1.1 200 OK - """ - headers = {'x-csrf-token': repr(self._connection)} - data = { "_method": "delete" } - request = self._connection.post('posts/{}/participation' - .format( self.id ), headers=headers, data=data) - if request.status_code != 200: - raise Exception('{0}: Failed to unsubscribe to post' - .format(request.status_code)) - - def report(self): - """ - TODO - """ - pass - - def delete(self): - """ This function deletes this post - """ - data = {'authenticity_token': repr(self._connection)} - request = self._connection.delete('posts/{0}'.format(self.id), - data=data, - headers={'accept': 'application/json'}) - if request.status_code != 204: - raise errors.PostError('{0}: Post could not be deleted'.format(request.status_code)) - - def delete_comment(self, comment_id): - """This function removes a comment from a post - - :param comment_id: id of the comment to remove. - :type comment_id: str - """ - data = {'authenticity_token': repr(self._connection)} - request = self._connection.delete('posts/{0}/comments/{1}' - .format(self.id, comment_id), - data=data, - headers={'accept': 'application/json'}) - - if request.status_code != 204: - raise errors.PostError('{0}: Comment could not be deleted' - .format(request.status_code)) - - def delete_like(self): - """This function removes a like from a post - """ - data = {'authenticity_token': repr(self._connection)} - url = 'posts/{0}/likes/{1}'.format(self.id, self._data['interactions']['likes'][0]['id']) - request = self._connection.delete(url, data=data) - if request.status_code != 204: - raise errors.PostError('{0}: Like could not be removed.' - .format(request.status_code)) - - def author(self, key='name'): - """Returns author of the post. - :param key: all keys available in data['author'] - """ - return self._data['author'][key] + """This class represents a post. + + .. note:: + Remember that you need to have access to the post. + """ + def __init__(self, connection, id=0, guid='', fetch=True, comments=True, post_data=None): + """ + :param id: id of the post (GUID is recommended) + :type id: int + :param guid: GUID of the post + :type guid: str + :param connection: connection object used to authenticate + :type connection: connection.Connection + :param fetch: defines whether to fetch post's data or not + :type fetch: bool + :param comments: defines whether to fetch post's comments or not (if True also data will be fetched) + :type comments: bool + :param post_data: contains post data so no need to fetch the post if this is set, until you want to update post data + :type: json + """ + if not (guid or id): raise TypeError('neither guid nor id was provided') + self._connection = connection + self.id = id + self.guid = guid + self._data = {} + self.comments = Comments() + if post_data: + self._data = post_data + + if fetch: self._fetchdata() + if comments: + if not self._data: self._fetchdata() + self._fetchcomments() + else: + if not self._data: self._fetchdata() + self.comments.set_json( self['interactions']['comments'] ) + + def __repr__(self): + """Returns string containing more information then str(). + """ + return '{0} ({1}): {2}'.format(self._data['author']['name'], self._data['author']['guid'], self._data['text']) + + def __str__(self): + """Returns text of a post. + """ + return self._data['text'] + + def __getitem__(self, key): + """FIXME This is deprecated, use diaspy.models.Post.data() instead to access + data of Post objects. + """ + return self._data[key] + + def __dict__(self): + """Returns dictionary of posts data. + FIXME This is deprecated, use diaspy.models.Post.data() instead. + """ + return self._data + + def _fetchdata(self): + """This function retrieves data of the post. + + :returns: guid of post whose data was fetched + """ + if self.id: id = self.id + if self.guid: id = self.guid + request = self._connection.get('posts/{0}.json'.format(id)) + if request.status_code != 200: + raise errors.PostError('{0}: could not fetch data for post: {1}'.format(request.status_code, id)) + elif request: + self._data = request.json() + return self['guid'] + + def _fetchcomments(self): + """Retreives comments for this post. + Retrieving comments via GUID will result in 404 error. + DIASPORA* does not supply comments through /posts/:guid/ endpoint. + """ + id = self._data['id'] + if self['interactions']['comments_count']: + request = self._connection.get('posts/{0}/comments.json'.format(id)) + if request.status_code != 200: + raise errors.PostError('{0}: could not fetch comments for post: {1}'.format(request.status_code, id)) + else: + self.comments.set([Comment(c) for c in request.json()]) + + def update(self): + """Updates post data. + FIXME This is deprecated. + """ + print('diaspy.models.Post.update() is deprecated. Use diaspy.models.Post.update() instead.') + self._fetchdata() + self._fetchcomments() + + def fetch(self, comments = False): + """Fetches post data. + Use this function instead of diaspy.models.Post.update(). + """ + self._fetchdata() + if comments: + self._fetchcomments() + return self + + def data(self, data = None): + if data is not None: + self._data = data + return self._data + + def like(self): + """This function likes a post. + It abstracts the 'Like' functionality. + + :returns: dict -- json formatted like object. + """ + data = {'authenticity_token': repr(self._connection)} + + request = self._connection.post('posts/{0}/likes'.format(self.id), + data=data, + headers={'accept': 'application/json'}) + + if request.status_code != 201: + raise errors.PostError('{0}: Post could not be liked.' + .format(request.status_code)) + + likes_json = request.json() + if likes_json: + self._data['interactions']['likes'] = [likes_json] + return likes_json + + def reshare(self): + """This function reshares a post + """ + data = {'root_guid': self._data['guid'], + 'authenticity_token': repr(self._connection)} + + request = self._connection.post('reshares', + data=data, + headers={'accept': 'application/json'}) + if request.status_code != 201: + raise Exception('{0}: Post could not be reshared'.format(request.status_code)) + return request.json() + + def comment(self, text): + """This function comments on a post + + :param text: text to comment. + :type text: str + """ + data = {'text': text, + 'authenticity_token': repr(self._connection)} + request = self._connection.post('posts/{0}/comments'.format(self.id), + data=data, + headers={'accept': 'application/json'}) + + if request.status_code != 201: + raise Exception('{0}: Comment could not be posted.' + .format(request.status_code)) + return Comment(request.json()) + + def vote_poll(self, poll_answer_id): + """This function votes on a post's poll + + :param poll_answer_id: id to poll vote. + :type poll_answer_id: int + """ + poll_id = self._data['poll']['poll_id'] + data = {'poll_answer_id': poll_answer_id, + 'poll_id': poll_id, + 'post_id': self.id, + 'authenticity_token': repr(self._connection)} + request = self._connection.post('posts/{0}/poll_participations'.format(self.id), + data=data, + headers={'accept': 'application/json'}) + if request.status_code != 201: + raise Exception('{0}: Vote on poll failed.' + .format(request.status_code)) + return request.json() + + def hide(self): + """ + -> PUT /share_visibilities/42 HTTP/1.1 + post_id=123 + <- HTTP/1.1 200 OK + """ + headers = {'x-csrf-token': repr(self._connection)} + params = {'post_id': json.dumps(self.id)} + request = self._connection.put('share_visibilities/42', params=params, headers=headers) + if request.status_code != 200: + raise Exception('{0}: Failed to hide post.' + .format(request.status_code)) + + def mute(self): + """ + -> POST /blocks HTTP/1.1 + {"block":{"person_id":123}} + <- HTTP/1.1 204 No Content + """ + headers = {'content-type':'application/json', 'x-csrf-token': repr(self._connection)} + data = json.dumps({ 'block': { 'person_id' : self._data['author']['id'] } }) + request = self._connection.post('blocks', data=data, headers=headers) + if request.status_code != 204: + raise Exception('{0}: Failed to block person' + .format(request.status_code)) + + def subscribe(self): + """ + -> POST /posts/123/participation HTTP/1.1 + <- HTTP/1.1 201 Created + """ + headers = {'x-csrf-token': repr(self._connection)} + data = {} + request = self._connection.post('posts/{}/participation' + .format( self.id ), data=data, headers=headers) + if request.status_code != 201: + raise Exception('{0}: Failed to subscribe to post' + .format(request.status_code)) + + def unsubscribe(self): + """ + -> POST /posts/123/participation HTTP/1.1 + _method=delete + <- HTTP/1.1 200 OK + """ + headers = {'x-csrf-token': repr(self._connection)} + data = { "_method": "delete" } + request = self._connection.post('posts/{}/participation' + .format( self.id ), headers=headers, data=data) + if request.status_code != 200: + raise Exception('{0}: Failed to unsubscribe to post' + .format(request.status_code)) + + def report(self): + """ + TODO + """ + pass + + def delete(self): + """ This function deletes this post + """ + data = {'authenticity_token': repr(self._connection)} + request = self._connection.delete('posts/{0}'.format(self.id), + data=data, + headers={'accept': 'application/json'}) + if request.status_code != 204: + raise errors.PostError('{0}: Post could not be deleted'.format(request.status_code)) + + def delete_comment(self, comment_id): + """This function removes a comment from a post + + :param comment_id: id of the comment to remove. + :type comment_id: str + """ + data = {'authenticity_token': repr(self._connection)} + request = self._connection.delete('posts/{0}/comments/{1}' + .format(self.id, comment_id), + data=data, + headers={'accept': 'application/json'}) + + if request.status_code != 204: + raise errors.PostError('{0}: Comment could not be deleted' + .format(request.status_code)) + + def delete_like(self): + """This function removes a like from a post + """ + data = {'authenticity_token': repr(self._connection)} + url = 'posts/{0}/likes/{1}'.format(self.id, self._data['interactions']['likes'][0]['id']) + request = self._connection.delete(url, data=data) + if request.status_code != 204: + raise errors.PostError('{0}: Like could not be removed.' + .format(request.status_code)) + + def author(self, key='name'): + """Returns author of the post. + :param key: all keys available in data['author'] + """ + return self._data['author'][key] diff --git a/diaspy/notifications.py b/diaspy/notifications.py index 1fbd44f..cb5b42f 100644 --- a/diaspy/notifications.py +++ b/diaspy/notifications.py @@ -11,90 +11,90 @@ from diaspy.models import Notification class Notifications(): - """This class represents notifications of a user. - """ - def __init__(self, connection): - self._connection = connection - self._data = {} - self._notifications = self.get() - self.page = 1 - - def __iter__(self): - return iter(self._notifications) - - def __getitem__(self, n): - return self._notifications[n] - - def _finalise(self, notifications): - self._data['unread_count'] = notifications['unread_count'] - self._data['unread_count_by_type'] = notifications['unread_count_by_type'] - return [Notification(self._connection, n) for n in notifications.get('notification_list', [])] - - def last(self): - """Returns list of most recent notifications. - """ - params = {'per_page': 5, '_': int(round(time.time(), 3)*1000)} - headers = {'x-csrf-token': repr(self._connection)} - - request = self._connection.get('notifications.json', headers=headers, params=params) - - if request.status_code != 200: - raise Exception('status code: {0}: cannot retreive notifications'.format(request.status_code)) - return self._finalise(request.json()) - - def _expand(self, new_notifications): - ids = [notification.id for notification in self._notifications] - notifications = self._notifications - data = self._data - for n in new_notifications: - if n.id not in ids: - if n.unread: - data['unread_count'] +=1 - data['unread_count_by_type'][n.type] +=1 - notifications.append(n) - ids.append(n.id) - self._notifications = notifications - self._data = data - - def _update(self, new_notifications): - ids = [notification.id for notification in self._notifications] - notifications = self._notifications - data = self._data - - update = False - if new_notifications[len(new_notifications)-1].id not in ids: - update = True - - for i in range(len(new_notifications)): - if new_notifications[-i].id not in ids: - if new_notifications[-i].unread: - data['unread_count'] +=1 - data['unread_count_by_type'][new_notifications[-i].type] +=1 - notifications = [new_notifications[-i]] + notifications - ids.append(new_notifications[-i].id) - self._notifications = notifications - self._data = data - if update: self.update() # if there is a gap - - def update(self, per_page=5, page=1): - result = self.get(per_page=per_page, page=page) - if result: self._update( result ) - - def more(self, per_page=5, page=0): - if not page: page = self.page + 1 - self.page = page - result = self.get(per_page=per_page, page=page) - if result: - self._expand( result ) - - def get(self, per_page=5, page=1): - """Returns list of notifications. - """ - params = {'per_page': per_page, 'page': page} - headers = {'x-csrf-token': repr(self._connection)} - - request = self._connection.get('notifications.json', headers=headers, params=params) - - if request.status_code != 200: - raise Exception('status code: {0}: cannot retreive notifications'.format(request.status_code)) - return self._finalise(request.json()) + """This class represents notifications of a user. + """ + def __init__(self, connection): + self._connection = connection + self._data = {} + self._notifications = self.get() + self.page = 1 + + def __iter__(self): + return iter(self._notifications) + + def __getitem__(self, n): + return self._notifications[n] + + def _finalise(self, notifications): + self._data['unread_count'] = notifications['unread_count'] + self._data['unread_count_by_type'] = notifications['unread_count_by_type'] + return [Notification(self._connection, n) for n in notifications.get('notification_list', [])] + + def last(self): + """Returns list of most recent notifications. + """ + params = {'per_page': 5, '_': int(round(time.time(), 3)*1000)} + headers = {'x-csrf-token': repr(self._connection)} + + request = self._connection.get('notifications.json', headers=headers, params=params) + + if request.status_code != 200: + raise Exception('status code: {0}: cannot retreive notifications'.format(request.status_code)) + return self._finalise(request.json()) + + def _expand(self, new_notifications): + ids = [notification.id for notification in self._notifications] + notifications = self._notifications + data = self._data + for n in new_notifications: + if n.id not in ids: + if n.unread: + data['unread_count'] +=1 + data['unread_count_by_type'][n.type] +=1 + notifications.append(n) + ids.append(n.id) + self._notifications = notifications + self._data = data + + def _update(self, new_notifications): + ids = [notification.id for notification in self._notifications] + notifications = self._notifications + data = self._data + + update = False + if new_notifications[len(new_notifications)-1].id not in ids: + update = True + + for i in range(len(new_notifications)): + if new_notifications[-i].id not in ids: + if new_notifications[-i].unread: + data['unread_count'] +=1 + data['unread_count_by_type'][new_notifications[-i].type] +=1 + notifications = [new_notifications[-i]] + notifications + ids.append(new_notifications[-i].id) + self._notifications = notifications + self._data = data + if update: self.update() # if there is a gap + + def update(self, per_page=5, page=1): + result = self.get(per_page=per_page, page=page) + if result: self._update( result ) + + def more(self, per_page=5, page=0): + if not page: page = self.page + 1 + self.page = page + result = self.get(per_page=per_page, page=page) + if result: + self._expand( result ) + + def get(self, per_page=5, page=1): + """Returns list of notifications. + """ + params = {'per_page': per_page, 'page': page} + headers = {'x-csrf-token': repr(self._connection)} + + request = self._connection.get('notifications.json', headers=headers, params=params) + + if request.status_code != 200: + raise Exception('status code: {0}: cannot retreive notifications'.format(request.status_code)) + return self._finalise(request.json()) diff --git a/diaspy/people.py b/diaspy/people.py index 5a88386..14a869a 100644 --- a/diaspy/people.py +++ b/diaspy/people.py @@ -12,393 +12,393 @@ from diaspy import search def sephandle(handle): - """Separate Diaspora* handle into pod pod and user. + """Separate Diaspora* handle into pod pod and user. - :returns: two-tuple (pod, user) - """ - if re.match('^[a-zA-Z]+[a-zA-Z0-9_-]*@[a-z0-9.]+\.[a-z]+$', handle) is None: - raise errors.InvalidHandleError('{0}'.format(handle)) - handle = handle.split('@') - pod, user = handle[1], handle[0] - return (pod, user) + :returns: two-tuple (pod, user) + """ + if re.match('^[a-zA-Z]+[a-zA-Z0-9_-]*@[a-z0-9.]+\.[a-z]+$', handle) is None: + raise errors.InvalidHandleError('{0}'.format(handle)) + handle = handle.split('@') + pod, user = handle[1], handle[0] + return (pod, user) class User(): - """This class abstracts a D* user. - This object goes around the limitations of current D* API and will - extract user data using black magic. - However, no chickens are harmed when you use it. - - The parameter fetch should be either 'posts', 'data' or 'none'. By - default it is 'posts' which means in addition to user data, stream - will be fetched. If user has not posted yet diaspy will not be able - to extract the information from his/her posts. Since there is no official - way to do it we rely on user posts. If this will be the case user - will be notified with appropriate exception message. - - If fetch is 'data', only user data will be fetched. If the user is - not found, no exception will be returned. - - When creating new User() one can pass either guid, handle and/or id as - optional parameters. GUID takes precedence over handle when fetching - user stream. When fetching user data, handle is required. - """ - @classmethod - def parse(cls, connection, data): - person = data.get('person') - if person is None: - raise errors.KeyMissingFromFetchedData('person', data) - - guid = person.get('guid') - if guid is None: - raise errors.KeyMissingFromFetchedData('guid', person) - - handle = person.get('diaspora_id') - if handle is None: - raise errors.KeyMissingFromFetchedData('diaspora_id', person) - - person_id = person.get('id') - if person_id is None: - raise errors.KeyMissingFromFetchedData('id', person) - - return User(connection, guid, handle, id, data=data) - - def __init__(self, connection, guid='', handle='', fetch='posts', id=0, data=None): - self._connection = connection - self.stream = [] - self.data = { - 'guid': guid, - 'handle': handle, - 'id': id, - } - self.photos = [] - if data: self.data.update( data ) - if fetch: self._fetch(fetch) - - def __getitem__(self, key): - return self.data[key] - - def __str__(self): - return self.data.get('guid', '') - - def __repr__(self): - return '{0} ({1})'.format(self.handle(), self.guid()) - - def handle(self): - if 'handle' in self.data: return self['handle'] - return self.data.get('diaspora_id', 'Unknown handle') - - def guid(self): - return self.data.get('guid', '') - - def id(self): - return self.data['id'] - - def _fetchstream(self): - self.stream = Outer(self._connection, guid=self['guid']) - - def _fetch(self, fetch): - """Fetch user posts or data. - """ - if fetch == 'posts': - if self.handle() and not self['guid']: self.fetchhandle() - else: self.fetchguid() - elif fetch == 'data' and self['handle']: - self.fetchprofile() - - def _finalize_data(self, data): - """Adjustments are needed to have similar results returned - by search feature and fetchguid()/fetchhandle(). - """ - return data - - def _postproc(self, request): - """Makes necessary modifications to user data and - sets up a stream. - - :param request: request object - :type request: request - """ - if request.status_code != 200: raise Exception('wrong error code: {0}'.format(request.status_code)) - data = request.json() - self.data = self._finalize_data(data) - - def fetchhandle(self, protocol='https'): - """Fetch user data and posts using Diaspora handle. - """ - pod, user = sephandle(self['handle']) - request = self._connection.get('{0}://{1}/u/{2}.json'.format(protocol, pod, user), direct=True) - self._postproc(request) - self._fetchstream() - - def fetchguid(self, fetch_stream=True): - """Fetch user data and posts (if fetch_stream is True) using guid. - """ - if self['guid']: - request = self._connection.get('people/{0}.json'.format(self['guid'])) - self._postproc(request) - if fetch_stream: self._fetchstream() - else: - raise errors.UserError('GUID not set') - - def fetchprofile(self): - """Fetches user data. - """ - data = search.Search(self._connection).user(self.handle()) - if not data: - raise errors.UserError('user with handle "{0}" has not been found on pod "{1}"'.format(self.handle(), self._connection.pod)) - else: - self.data.update( data[0] ) - - def aspectMemberships(self): - if 'contact' in self.data: - return self.data.get('contact', {}).get('aspect_memberships', []) - else: - return self.data.get('aspect_memberships', []) - - def getPhotos(self): - """ - --> GET /people/{GUID}/photos.json HTTP/1.1 - - <-- HTTP/1.1 200 OK - - { - "photos":[ - { - "id":{photo_id}, - "guid":"{photo_guid}", - "created_at":"2018-03-08T23:48:31.000Z", - "author":{ - "id":{author_id}, - "guid":"{author_guid}", - "name":"{author_name}", - "diaspora_id":"{diaspora_id}", - "avatar":{"small":"{avatar_url_small}","medium":"{avatar_url_medium}","large":"{avatar_url_large}"} - }, - "sizes":{ - "small":"{photo_url}", - "medium":"{photo_url}", - "large":"{photo_url}" - }, - "dimensions":{"height":847,"width":998}, - "status_message":{ - "id":{post_id} - } - },{ .. - } - - if there are no photo's it returns: - {"photos":[]} - """ - - request = self._connection.get('/people/{0}/photos.json'.format(self['guid'])) - if request.status_code != 200: raise errors.UserError('could not fetch photos for user: {0}'.format(self['guid'])) - - json = request.json() - if json: self.photos = json['photos'] - return json['photos'] - - def getHCard(self): - """Returns json containing user HCard. - --> /people/{guid}/hovercard.json?_={timestamp} - - <-- HTTP/2.0 200 OK - { - "id":123, - "guid":"1234567890abcdef", - "name":"test", - "diaspora_id":"batman@test.test", - "contact":false, - "profile":{ - "avatar":"https://nicetesturl.url/image.jpg", - "tags":["tag1", "tag2", "tag3", "tag4", "tag5"]} - } - """ - timestamp = int(time.mktime(time.gmtime())) - request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp)) - if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid'])) - return request.json() - - def deletePhoto(self, photo_id): - """ - --> DELETE /photos/{PHOTO_ID} HTTP/1.1 - <-- HTTP/1.1 204 No Content - """ - request = self._connection.delete('/photos/{0}'.format(photo_id)) - if request.status_code != 204: raise errors.UserError('could not delete photo_id: {0}'.format(photo_id)) + """This class abstracts a D* user. + This object goes around the limitations of current D* API and will + extract user data using black magic. + However, no chickens are harmed when you use it. + + The parameter fetch should be either 'posts', 'data' or 'none'. By + default it is 'posts' which means in addition to user data, stream + will be fetched. If user has not posted yet diaspy will not be able + to extract the information from his/her posts. Since there is no official + way to do it we rely on user posts. If this will be the case user + will be notified with appropriate exception message. + + If fetch is 'data', only user data will be fetched. If the user is + not found, no exception will be returned. + + When creating new User() one can pass either guid, handle and/or id as + optional parameters. GUID takes precedence over handle when fetching + user stream. When fetching user data, handle is required. + """ + @classmethod + def parse(cls, connection, data): + person = data.get('person') + if person is None: + raise errors.KeyMissingFromFetchedData('person', data) + + guid = person.get('guid') + if guid is None: + raise errors.KeyMissingFromFetchedData('guid', person) + + handle = person.get('diaspora_id') + if handle is None: + raise errors.KeyMissingFromFetchedData('diaspora_id', person) + + person_id = person.get('id') + if person_id is None: + raise errors.KeyMissingFromFetchedData('id', person) + + return User(connection, guid, handle, id, data=data) + + def __init__(self, connection, guid='', handle='', fetch='posts', id=0, data=None): + self._connection = connection + self.stream = [] + self.data = { + 'guid': guid, + 'handle': handle, + 'id': id, + } + self.photos = [] + if data: self.data.update( data ) + if fetch: self._fetch(fetch) + + def __getitem__(self, key): + return self.data[key] + + def __str__(self): + return self.data.get('guid', '') + + def __repr__(self): + return '{0} ({1})'.format(self.handle(), self.guid()) + + def handle(self): + if 'handle' in self.data: return self['handle'] + return self.data.get('diaspora_id', 'Unknown handle') + + def guid(self): + return self.data.get('guid', '') + + def id(self): + return self.data['id'] + + def _fetchstream(self): + self.stream = Outer(self._connection, guid=self['guid']) + + def _fetch(self, fetch): + """Fetch user posts or data. + """ + if fetch == 'posts': + if self.handle() and not self['guid']: self.fetchhandle() + else: self.fetchguid() + elif fetch == 'data' and self['handle']: + self.fetchprofile() + + def _finalize_data(self, data): + """Adjustments are needed to have similar results returned + by search feature and fetchguid()/fetchhandle(). + """ + return data + + def _postproc(self, request): + """Makes necessary modifications to user data and + sets up a stream. + + :param request: request object + :type request: request + """ + if request.status_code != 200: raise Exception('wrong error code: {0}'.format(request.status_code)) + data = request.json() + self.data = self._finalize_data(data) + + def fetchhandle(self, protocol='https'): + """Fetch user data and posts using Diaspora handle. + """ + pod, user = sephandle(self['handle']) + request = self._connection.get('{0}://{1}/u/{2}.json'.format(protocol, pod, user), direct=True) + self._postproc(request) + self._fetchstream() + + def fetchguid(self, fetch_stream=True): + """Fetch user data and posts (if fetch_stream is True) using guid. + """ + if self['guid']: + request = self._connection.get('people/{0}.json'.format(self['guid'])) + self._postproc(request) + if fetch_stream: self._fetchstream() + else: + raise errors.UserError('GUID not set') + + def fetchprofile(self): + """Fetches user data. + """ + data = search.Search(self._connection).user(self.handle()) + if not data: + raise errors.UserError('user with handle "{0}" has not been found on pod "{1}"'.format(self.handle(), self._connection.pod)) + else: + self.data.update( data[0] ) + + def aspectMemberships(self): + if 'contact' in self.data: + return self.data.get('contact', {}).get('aspect_memberships', []) + else: + return self.data.get('aspect_memberships', []) + + def getPhotos(self): + """ + --> GET /people/{GUID}/photos.json HTTP/1.1 + + <-- HTTP/1.1 200 OK + + { + "photos":[ + { + "id":{photo_id}, + "guid":"{photo_guid}", + "created_at":"2018-03-08T23:48:31.000Z", + "author":{ + "id":{author_id}, + "guid":"{author_guid}", + "name":"{author_name}", + "diaspora_id":"{diaspora_id}", + "avatar":{"small":"{avatar_url_small}","medium":"{avatar_url_medium}","large":"{avatar_url_large}"} + }, + "sizes":{ + "small":"{photo_url}", + "medium":"{photo_url}", + "large":"{photo_url}" + }, + "dimensions":{"height":847,"width":998}, + "status_message":{ + "id":{post_id} + } + },{ .. + } + + if there are no photo's it returns: + {"photos":[]} + """ + + request = self._connection.get('/people/{0}/photos.json'.format(self['guid'])) + if request.status_code != 200: raise errors.UserError('could not fetch photos for user: {0}'.format(self['guid'])) + + json = request.json() + if json: self.photos = json['photos'] + return json['photos'] + + def getHCard(self): + """Returns json containing user HCard. + --> /people/{guid}/hovercard.json?_={timestamp} + + <-- HTTP/2.0 200 OK + { + "id":123, + "guid":"1234567890abcdef", + "name":"test", + "diaspora_id":"batman@test.test", + "contact":false, + "profile":{ + "avatar":"https://nicetesturl.url/image.jpg", + "tags":["tag1", "tag2", "tag3", "tag4", "tag5"]} + } + """ + timestamp = int(time.mktime(time.gmtime())) + request = self._connection.get('/people/{0}/hovercard.json?_={}'.format(self['guid'], timestamp)) + if request.status_code != 200: raise errors.UserError('could not fetch hcard for user: {0}'.format(self['guid'])) + return request.json() + + def deletePhoto(self, photo_id): + """ + --> DELETE /photos/{PHOTO_ID} HTTP/1.1 + <-- HTTP/1.1 204 No Content + """ + request = self._connection.delete('/photos/{0}'.format(photo_id)) + if request.status_code != 204: raise errors.UserError('could not delete photo_id: {0}'.format(photo_id)) class Me(): - """Object represetnting current user. - """ - _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})') - _userinfo_regex_2 = re.compile(r'gon.user=({.*});gon.preloads') + """Object represetnting current user. + """ + _userinfo_regex = re.compile(r'window.current_user_attributes = ({.*})') + _userinfo_regex_2 = re.compile(r'gon.user=({.*});gon.preloads') - def __init__(self, connection): - self._connection = connection + def __init__(self, connection): + self._connection = connection - def getInfo(self): - """This function returns the current user's attributes. + def getInfo(self): + """This function returns the current user's attributes. - :returns: dict - """ - request = self._connection.get('bookmarklet') - userdata = self._userinfo_regex.search(request.text) - if userdata is None: userdata = self._userinfo_regex_2.search(request.text) - if userdata is None: raise errors.DiaspyError('cannot find user data') - userdata = userdata.group(1) - return json.loads(userdata) + :returns: dict + """ + request = self._connection.get('bookmarklet') + userdata = self._userinfo_regex.search(request.text) + if userdata is None: userdata = self._userinfo_regex_2.search(request.text) + if userdata is None: raise errors.DiaspyError('cannot find user data') + userdata = userdata.group(1) + return json.loads(userdata) class Contacts(): - """This class represents user's list of contacts. - """ - def __init__(self, connection, fetch=False, set=''): - self._connection = connection - self.contacts = None - if fetch: self.contacts = self.get(set) - - def __getitem__(self, index): - return self.contacts[index] - - def addAspect(self, name, visible=False): - """ - --> POST /aspects HTTP/1.1 - --> {"person_id":null,"name":"test","contacts_visible":false} - - <-- HTTP/1.1 200 OK - - Add new aspect. - - TODO: status_code's - - :param name: aspect name to add - :type name: str - :param visible: sets if contacts in aspect are visible for each and other - :type visible: bool - :returns: JSON from request - """ - data = { - 'person_id': None, - 'name': name, - 'contacts_visible': visible - } - headers={'content-type': 'application/json', - 'accept': 'application/json' } - request = self._connection.tokenFrom('contacts').post('aspects', headers=headers, data=json.dumps(data)) - - if request.status_code == 400: - raise errors.AspectError('duplicate record, aspect alreadt exists: {0}'.format(request.status_code)) - elif request.status_code != 200: - raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) - - new_aspect = request.json() - self._connection.userdata()['aspects'].append( new_aspect ) - - return new_aspect - - def deleteAspect(self, aspect_id): - """ - --> POST /aspects/{ASPECT_ID} HTTP/1.1 - _method=delete&authenticity_token={TOKEN} - Content-Type: application/x-www-form-urlencoded - - <-- HTTP/1.1 302 Found - Content-Type: text/html; charset=utf-8 - """ - request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format( aspect_id )) - - if request.status_code != 200: # since we don't post but delete - raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) - - def add(self, user_id, aspect_ids): - """Add user to aspects of given ids. - - :param user_id: user id (not guid) - :type user_id: str - :param aspect_ids: list of aspect ids - :type aspect_ids: list - """ - # TODO update self.contacts - # Returns {"aspect_id":123,"person_id":123} - for aid in aspect_ids: - new_aspect_membership = Aspect(self._connection, aid).addUser(user_id) - - # user. - if new_aspect_membership: - for user in self.contacts: - if int(user.data['person_id']) == int(user_id): - user.data['aspect_memberships'].append( new_aspect_membership ) - return new_aspect_membership - - def remove(self, user_id, aspect_ids): - """Remove user from aspects of given ids. - - :param user_id: user id - :type user_id: str - :param aspect_ids: list of aspect ids - :type aspect_ids: list - """ - for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id) - - def get(self, set='', page=0): - """Returns list of user contacts. - Contact is a User() who is in one or more of user's - aspects. - - By default, it will return list of users who are in - user's aspects. - - If `set` is `all` it will also include users who only share - with logged user and are not in his/hers aspects. - - If `set` is `only_sharing` it will return users who are only - sharing with logged user and ARE NOT in his/hers aspects. - - # On "All contacts" button diaspora - on the time of testing this I had 20 contacts and 10 that - where only sharing with me. So 30 in total. - - --> GET /contacts?set=all HTTP/1.1 - <-- HTTP/1.1 200 OK - returned 25 contacts (5 only sharing with me) - - --> GET /contacts.json?page=1&set=all&_=1524410225376 HTTP/1.1 - <-- HTTP/1.1 200 OK - returned the same list as before. - - --> GET /contacts.json?page=2&set=all&_=1524410225377 HTTP/1.1 - <-- HTTP/1.1 200 OK - returned the other 5 that where only sharing with me. - - --> GET /contacts.json?page=3&set=all&_=1524410225378 HTTP/1.1 - <-- HTTP/1.1 200 OK - returned empty list. - - It appears that /contacts?set=all returns a maximum of 25 - contacts. - - So if /contacts?set=all returns 25 contacts then request next - page until page returns a list with less then 25. I don't see a - reason why we should request page=1 'cause the previous request - will be the same. So begin with page=2 if /contacts?set=all - returns 25. - - :param set: if passed could be 'all' or 'only_sharing' - :type set: str - """ - params = {} - if set: - params['set'] = set - params['_'] = int(time.mktime(time.gmtime())) - if page: params['page'] = page - - request = self._connection.get('contacts.json', params=params) - if request.status_code != 200: - raise Exception('status code {0}: cannot get contacts'.format(request.status_code)) - - json = request.json() - users = [User.parse(self._connection, each) for each in json] - if len(json) == 25: - if not page: page = 1 - users += self.get(set=set, page=page+1) - return users + """This class represents user's list of contacts. + """ + def __init__(self, connection, fetch=False, set=''): + self._connection = connection + self.contacts = None + if fetch: self.contacts = self.get(set) + + def __getitem__(self, index): + return self.contacts[index] + + def addAspect(self, name, visible=False): + """ + --> POST /aspects HTTP/1.1 + --> {"person_id":null,"name":"test","contacts_visible":false} + + <-- HTTP/1.1 200 OK + + Add new aspect. + + TODO: status_code's + + :param name: aspect name to add + :type name: str + :param visible: sets if contacts in aspect are visible for each and other + :type visible: bool + :returns: JSON from request + """ + data = { + 'person_id': None, + 'name': name, + 'contacts_visible': visible + } + headers={'content-type': 'application/json', + 'accept': 'application/json' } + request = self._connection.tokenFrom('contacts').post('aspects', headers=headers, data=json.dumps(data)) + + if request.status_code == 400: + raise errors.AspectError('duplicate record, aspect alreadt exists: {0}'.format(request.status_code)) + elif request.status_code != 200: + raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) + + new_aspect = request.json() + self._connection.userdata()['aspects'].append( new_aspect ) + + return new_aspect + + def deleteAspect(self, aspect_id): + """ + --> POST /aspects/{ASPECT_ID} HTTP/1.1 + _method=delete&authenticity_token={TOKEN} + Content-Type: application/x-www-form-urlencoded + + <-- HTTP/1.1 302 Found + Content-Type: text/html; charset=utf-8 + """ + request = self._connection.tokenFrom('contacts').delete('aspects/{}'.format( aspect_id )) + + if request.status_code != 200: # since we don't post but delete + raise errors.AspectError('wrong status code: {0}'.format(request.status_code)) + + def add(self, user_id, aspect_ids): + """Add user to aspects of given ids. + + :param user_id: user id (not guid) + :type user_id: str + :param aspect_ids: list of aspect ids + :type aspect_ids: list + """ + # TODO update self.contacts + # Returns {"aspect_id":123,"person_id":123} + for aid in aspect_ids: + new_aspect_membership = Aspect(self._connection, aid).addUser(user_id) + + # user. + if new_aspect_membership: + for user in self.contacts: + if int(user.data['person_id']) == int(user_id): + user.data['aspect_memberships'].append( new_aspect_membership ) + return new_aspect_membership + + def remove(self, user_id, aspect_ids): + """Remove user from aspects of given ids. + + :param user_id: user id + :type user_id: str + :param aspect_ids: list of aspect ids + :type aspect_ids: list + """ + for aid in aspect_ids: Aspect(self._connection, aid).removeUser(user_id) + + def get(self, set='', page=0): + """Returns list of user contacts. + Contact is a User() who is in one or more of user's + aspects. + + By default, it will return list of users who are in + user's aspects. + + If `set` is `all` it will also include users who only share + with logged user and are not in his/hers aspects. + + If `set` is `only_sharing` it will return users who are only + sharing with logged user and ARE NOT in his/hers aspects. + + # On "All contacts" button diaspora + on the time of testing this I had 20 contacts and 10 that + where only sharing with me. So 30 in total. + + --> GET /contacts?set=all HTTP/1.1 + <-- HTTP/1.1 200 OK + returned 25 contacts (5 only sharing with me) + + --> GET /contacts.json?page=1&set=all&_=1524410225376 HTTP/1.1 + <-- HTTP/1.1 200 OK + returned the same list as before. + + --> GET /contacts.json?page=2&set=all&_=1524410225377 HTTP/1.1 + <-- HTTP/1.1 200 OK + returned the other 5 that where only sharing with me. + + --> GET /contacts.json?page=3&set=all&_=1524410225378 HTTP/1.1 + <-- HTTP/1.1 200 OK + returned empty list. + + It appears that /contacts?set=all returns a maximum of 25 + contacts. + + So if /contacts?set=all returns 25 contacts then request next + page until page returns a list with less then 25. I don't see a + reason why we should request page=1 'cause the previous request + will be the same. So begin with page=2 if /contacts?set=all + returns 25. + + :param set: if passed could be 'all' or 'only_sharing' + :type set: str + """ + params = {} + if set: + params['set'] = set + params['_'] = int(time.mktime(time.gmtime())) + if page: params['page'] = page + + request = self._connection.get('contacts.json', params=params) + if request.status_code != 200: + raise Exception('status code {0}: cannot get contacts'.format(request.status_code)) + + json = request.json() + users = [User.parse(self._connection, each) for each in json] + if len(json) == 25: + if not page: page = 1 + users += self.get(set=set, page=page+1) + return users diff --git a/diaspy/search.py b/diaspy/search.py index 11f77c7..5139175 100644 --- a/diaspy/search.py +++ b/diaspy/search.py @@ -8,41 +8,41 @@ from diaspy import errors class Search(): - """This object is used for searching for content on Diaspora*. - """ - def __init__(self, connection): - self._connection = connection - - def lookupUser(self, handle): - """This function will launch a webfinger lookup from the pod for the - handle requested. Response code is returned and if the lookup was successful, - user should soon be searchable via pod used for connection. - - :param string: Handle to search for. - """ - request = self._connection.get('people', headers={'accept': 'text/html'}, params={'q': handle}) - return request.status_code - - def user(self, query): - """Searches for a user. - Will return list of dictionaries containing - data of found users. - """ - request = self._connection.get('people.json', params={'q': query, 'utf-8': '%u2713'}) - if request.status_code != 200: - raise errors.SearchError('wrong status code: {0}'.format(request.status_code)) - return request.json() - - def tags(self, query, limit=10): - """Retrieve tag suggestions. - - :param query: query used to search - :type query: str - :param limit: maxmal number of suggestions returned - :type limit: int - """ - params = {'q': query, 'limit': limit} - request = self._connection.get('tags', params=params, headers={'x-csrf-token': repr(self._connection)}) - if request.status_code != 200: - raise errors.SearchError('wrong status code: {0}'.format(request.status_code)) - return [i['name'] for i in request.json()] + """This object is used for searching for content on Diaspora*. + """ + def __init__(self, connection): + self._connection = connection + + def lookupUser(self, handle): + """This function will launch a webfinger lookup from the pod for the + handle requested. Response code is returned and if the lookup was successful, + user should soon be searchable via pod used for connection. + + :param string: Handle to search for. + """ + request = self._connection.get('people', headers={'accept': 'text/html'}, params={'q': handle}) + return request.status_code + + def user(self, query): + """Searches for a user. + Will return list of dictionaries containing + data of found users. + """ + request = self._connection.get('people.json', params={'q': query, 'utf-8': '%u2713'}) + if request.status_code != 200: + raise errors.SearchError('wrong status code: {0}'.format(request.status_code)) + return request.json() + + def tags(self, query, limit=10): + """Retrieve tag suggestions. + + :param query: query used to search + :type query: str + :param limit: maxmal number of suggestions returned + :type limit: int + """ + params = {'q': query, 'limit': limit} + request = self._connection.get('tags', params=params, headers={'x-csrf-token': repr(self._connection)}) + if request.status_code != 200: + raise errors.SearchError('wrong status code: {0}'.format(request.status_code)) + return [i['name'] for i in request.json()] diff --git a/diaspy/settings.py b/diaspy/settings.py index c926efd..5d1cdf4 100644 --- a/diaspy/settings.py +++ b/diaspy/settings.py @@ -14,296 +14,296 @@ from diaspy import errors, streams class Account(): - """Provides interface to account settings. - """ - email_regexp = re.compile('(.*?)') - - def __init__(self, connection): - self._connection = connection - - def downloadxml(self): - """Returns downloaded XML. - """ - request = self._connection.get('user/export') - return request.text - - def downloadPhotos(self, size='large', path='.', mark_nsfw=True, _critical=False, _stream=None): - """Downloads photos into the current working directory. - Sizes are: large, medium, small. - Filename is: {post_guid}_{photo_guid}.{extension} - - Normally, this method will catch urllib-generated errors and - just issue warnings about photos that couldn't be downloaded. - However, with _critical param set to True errors will become - critical - the will be reraised in finally block. - - :param size: size of the photos to download - large, medium or small - :type size: str - :param path: path to download (defaults to current working directory - :type path: str - :param mark_nsfw: will append '-nsfw' to images from posts marked as nsfw, - :type mark_nsfw: bool - :param _stream: diaspy.streams.Generic-like object (only for testing) - :param _critical: if True urllib errors will be reraised after generating a warning (may be removed) - - :returns: integer, number of photos downloaded - """ - photos = 0 - if _stream is None: - stream = streams.Activity(self._connection) - stream.full() - else: - stream = _stream - for i, post in enumerate(stream): - if post['nsfw'] is not False: nsfw = '-nsfw' - else: nsfw = '' - if post['photos']: - for n, photo in enumerate(post['photos']): - # photo format -- .jpg, .png etc. - ext = photo['sizes'][size].split('.')[-1] - name = '{0}_{1}{2}.{3}'.format(post['guid'], photo['guid'], nsfw, ext) - filename = os.path.join(path, name) - try: - urllib.request.urlretrieve(url=photo['sizes'][size], filename=filename) - except (urllib.error.HTTPError, urllib.error.URLError) as e: - warnings.warn('downloading image {0} from post {1}: {2}'.format(photo['guid'], post['guid'], e)) - finally: - if _critical: raise - photos += 1 - return photos - - def setEmail(self, email): - """Changes user's email. - """ - data = {'_method': 'put', 'utf8': '✓', 'user[email]': email, 'authenticity_token': repr(self._connection)} - request = self._connection.post('user', data=data, allow_redirects=False) - if request.status_code != 302: - raise errors.SettingsError('setting email failed: {0}'.format(request.status_code)) - - def getEmail(self): - """Returns currently used email. - """ - data = self._connection.get('user/edit') - email = self.email_regexp.search(data.text) - if email is None: email = '' - else: email = email.group(1) - return email - - def setLanguage(self, lang): - """Changes user's email. - - :param lang: language identifier from getLanguages() - """ - data = {'_method': 'put', 'utf8': '✓', 'user[language]': lang, 'authenticity_token': repr(self._connection)} - request = self._connection.post('user', data=data, allow_redirects=False) - if request.status_code != 302: - raise errors.SettingsError('setting language failed: {0}'.format(request.status_code)) - - def getLanguages(self): - """Returns a list of tuples containing ('Language name', 'identifier'). - One of the Black Magic(tm) methods. - """ - request = self._connection.get('user/edit') - return self.language_option_regexp.findall(request.text) + """Provides interface to account settings. + """ + email_regexp = re.compile('(.*?)') + + def __init__(self, connection): + self._connection = connection + + def downloadxml(self): + """Returns downloaded XML. + """ + request = self._connection.get('user/export') + return request.text + + def downloadPhotos(self, size='large', path='.', mark_nsfw=True, _critical=False, _stream=None): + """Downloads photos into the current working directory. + Sizes are: large, medium, small. + Filename is: {post_guid}_{photo_guid}.{extension} + + Normally, this method will catch urllib-generated errors and + just issue warnings about photos that couldn't be downloaded. + However, with _critical param set to True errors will become + critical - the will be reraised in finally block. + + :param size: size of the photos to download - large, medium or small + :type size: str + :param path: path to download (defaults to current working directory + :type path: str + :param mark_nsfw: will append '-nsfw' to images from posts marked as nsfw, + :type mark_nsfw: bool + :param _stream: diaspy.streams.Generic-like object (only for testing) + :param _critical: if True urllib errors will be reraised after generating a warning (may be removed) + + :returns: integer, number of photos downloaded + """ + photos = 0 + if _stream is None: + stream = streams.Activity(self._connection) + stream.full() + else: + stream = _stream + for i, post in enumerate(stream): + if post['nsfw'] is not False: nsfw = '-nsfw' + else: nsfw = '' + if post['photos']: + for n, photo in enumerate(post['photos']): + # photo format -- .jpg, .png etc. + ext = photo['sizes'][size].split('.')[-1] + name = '{0}_{1}{2}.{3}'.format(post['guid'], photo['guid'], nsfw, ext) + filename = os.path.join(path, name) + try: + urllib.request.urlretrieve(url=photo['sizes'][size], filename=filename) + except (urllib.error.HTTPError, urllib.error.URLError) as e: + warnings.warn('downloading image {0} from post {1}: {2}'.format(photo['guid'], post['guid'], e)) + finally: + if _critical: raise + photos += 1 + return photos + + def setEmail(self, email): + """Changes user's email. + """ + data = {'_method': 'put', 'utf8': '✓', 'user[email]': email, 'authenticity_token': repr(self._connection)} + request = self._connection.post('user', data=data, allow_redirects=False) + if request.status_code != 302: + raise errors.SettingsError('setting email failed: {0}'.format(request.status_code)) + + def getEmail(self): + """Returns currently used email. + """ + data = self._connection.get('user/edit') + email = self.email_regexp.search(data.text) + if email is None: email = '' + else: email = email.group(1) + return email + + def setLanguage(self, lang): + """Changes user's email. + + :param lang: language identifier from getLanguages() + """ + data = {'_method': 'put', 'utf8': '✓', 'user[language]': lang, 'authenticity_token': repr(self._connection)} + request = self._connection.post('user', data=data, allow_redirects=False) + if request.status_code != 302: + raise errors.SettingsError('setting language failed: {0}'.format(request.status_code)) + + def getLanguages(self): + """Returns a list of tuples containing ('Language name', 'identifier'). + One of the Black Magic(tm) methods. + """ + request = self._connection.get('user/edit') + return self.language_option_regexp.findall(request.text) class Privacy(): - """Provides interface to provacy settings. - """ - def __init__(self, connection): - self._connection = connection + """Provides interface to provacy settings. + """ + def __init__(self, connection): + self._connection = connection class Profile(): - """Provides interface to profile settigns. - - WARNING: - - Because of the way update requests for profile are created every field must be sent. - The `load()` method is used to load all information into the dictionary. - Setters can then be used to adjust the data. - Finally, `update()` can be called to send data back to pod. - """ - firstname_regexp = re.compile('id="profile_first_name" name="profile\[first_name\]" type="text" value="(.*?)" />') - lastname_regexp = re.compile('id="profile_last_name" name="profile\[last_name\]" type="text" value="(.*?)" />') - bio_regexp = re.compile('') - location_regexp = re.compile('id="profile_location" name="profile\[location\]" placeholder="Fill me out" type="text" value="(.*?)" />') - gender_regexp = re.compile('id="profile_gender" name="profile\[gender\]" placeholder="Fill me out" type="text" value="(.*?)" />') - birth_year_regexp = re.compile('selected="selected" value="([0-9]{4,4})">[0-9]{4,4}') - birth_month_regexp = re.compile('selected="selected" value="([0-9]{1,2})">(.*?)') - birth_day_regexp = re.compile('selected="selected" value="([0-9]{1,2})">[0-9]{1,2}') - is_searchable_regexp = re.compile('checked="checked" id="profile_searchable" name="profile\[searchable\]" type="checkbox" value="(.*?)" />') - is_nsfw_regexp = re.compile('checked="checked" id="profile_nsfw" name="profile\[nsfw\]" type="checkbox" value="(.*?)" />') - - def __init__(self, connection, no_load=False): - self._connection = connection - self.data = {'utf-8': '✓', - '_method': 'put', - 'profile[first_name]': '', - 'profile[last_name]': '', - 'profile[tag_string]': '', - 'tags': '', - 'file': '', - 'profile[bio]': '', - 'profile[location]': '', - 'profile[gender]': '', - 'profile[date][year]': '', - 'profile[date][month]': '', - 'profile[date][day]': '', - } - self._html = self._fetchhtml() - self._loaded = False - if not no_load: self.load() - - def _fetchhtml(self): - """Fetches html that will be used to extract data. - """ - return self._connection.get('profile/edit').text - - def getName(self): - """Returns two-tuple: (first, last) name. - """ - first = self.firstname_regexp.search(self._html).group(1) - last = self.lastname_regexp.search(self._html).group(1) - return (first, last) - - def getTags(self): - """Returns tags user had selected when describing him/her-self. - """ - guid = self._connection.getUserData()['guid'] - html = self._connection.get('people/{0}'.format(guid)).text - description_regexp = re.compile('#.*?') - return [tag.lower() for tag in re.findall(description_regexp, html)] - - def getBio(self): - """Returns user bio. - """ - bio = self.bio_regexp.search(self._html).group(1) - return bio - - def getLocation(self): - """Returns location string. - """ - location = self.location_regexp.search(self._html).group(1) - return location - - def getGender(self): - """Returns location string. - """ - gender = self.gender_regexp.search(self._html).group(1) - return gender - - def getBirthDate(self, named_month=False): - """Returns three-tuple: (year, month, day). - - :param named_month: if True, return name of the month instead of integer - :type named_month: bool - """ - year = self.birth_year_regexp.search(self._html) - if year is None: year = -1 - else: year = int(year.group(1)) - month = self.birth_month_regexp.search(self._html) - if month is None: - if named_month: month = '' - else: month = -1 - else: - if named_month: - month = month.group(2) - else: - month = int(month.group(1)) - day = self.birth_day_regexp.search(self._html) - if day is None: day = -1 - else: day = int(day.group(1)) - return (year, month, day) - - def isSearchable(self): - """Returns True if profile is searchable. - """ - searchable = self.is_searchable_regexp.search(self._html) - # this is because value="true" in every case so we just - # check if the field is "checked" - if searchable is None: searchable = False # if it isn't - the regexp just won't match - else: searchable = True - return searchable - - def isNSFW(self): - """Returns True if profile is marked as NSFW. - """ - nsfw = self.is_nsfw_regexp.search(self._html) - if nsfw is None: nsfw = False - else: nsfw = True - return nsfw - - def setName(self, first, last): - """Set first and last name. - """ - self.data['profile[first_name]'] = first - self.data['profile[last_name]'] = last - - def setTags(self, tags): - """Sets tags that describe the user. - """ - self.data['tags'] = ', '.join(['#{}'.format(tag) for tag in tags]) - - def setBio(self, bio): - """Set bio of a user. - """ - self.data['profile[bio]'] = bio - - def setLocation(self, location): - """Set location of a user. - """ - self.data['profile[location]'] = location - - def setGender(self, gender): - """Set gender of a user. - """ - self.data['profile[gender]'] = gender - - def setBirthDate(self, year, month, day): - """Set birth date of a user. - """ - self.data['profile[date][year]'] = year - self.data['profile[date][month]'] = month - self.data['profile[date][day]'] = day - - def setSearchable(self, searchable): - """Set user's searchable status. - """ - self.data['profile[searchable]'] = json.dumps(searchable) - - def setNSFW(self, nsfw): - """Set user NSFW status. - """ - self.data['profile[nsfw]'] = json.dumps(nsfw) - - def load(self): - """Loads profile data into self.data dictionary. - **Notice:** Not all keys are loaded yet. - """ - self.setName(*self.getName()) - self.setBio(self.getBio()) - self.setLocation(self.getLocation()) - self.setGender(self.getGender()) - self.setBirthDate(*self.getBirthDate(named_month=False)) - self.setSearchable(self.isSearchable()) - self.setNSFW(self.isNSFW()) - self.setTags(self.getTags()) - self._loaded = True - - def update(self): - """Updates profile information. - """ - if not self._loaded: raise errors.DiaspyError('profile was not loaded') - self.data['authenticity_token'] = repr(self._connection) - print(self.data) - request = self._connection.post('profile', data=self.data, allow_redirects=False) - return request.status_code + """Provides interface to profile settigns. + + WARNING: + + Because of the way update requests for profile are created every field must be sent. + The `load()` method is used to load all information into the dictionary. + Setters can then be used to adjust the data. + Finally, `update()` can be called to send data back to pod. + """ + firstname_regexp = re.compile('id="profile_first_name" name="profile\[first_name\]" type="text" value="(.*?)" />') + lastname_regexp = re.compile('id="profile_last_name" name="profile\[last_name\]" type="text" value="(.*?)" />') + bio_regexp = re.compile('') + location_regexp = re.compile('id="profile_location" name="profile\[location\]" placeholder="Fill me out" type="text" value="(.*?)" />') + gender_regexp = re.compile('id="profile_gender" name="profile\[gender\]" placeholder="Fill me out" type="text" value="(.*?)" />') + birth_year_regexp = re.compile('selected="selected" value="([0-9]{4,4})">[0-9]{4,4}') + birth_month_regexp = re.compile('selected="selected" value="([0-9]{1,2})">(.*?)') + birth_day_regexp = re.compile('selected="selected" value="([0-9]{1,2})">[0-9]{1,2}') + is_searchable_regexp = re.compile('checked="checked" id="profile_searchable" name="profile\[searchable\]" type="checkbox" value="(.*?)" />') + is_nsfw_regexp = re.compile('checked="checked" id="profile_nsfw" name="profile\[nsfw\]" type="checkbox" value="(.*?)" />') + + def __init__(self, connection, no_load=False): + self._connection = connection + self.data = {'utf-8': '✓', + '_method': 'put', + 'profile[first_name]': '', + 'profile[last_name]': '', + 'profile[tag_string]': '', + 'tags': '', + 'file': '', + 'profile[bio]': '', + 'profile[location]': '', + 'profile[gender]': '', + 'profile[date][year]': '', + 'profile[date][month]': '', + 'profile[date][day]': '', + } + self._html = self._fetchhtml() + self._loaded = False + if not no_load: self.load() + + def _fetchhtml(self): + """Fetches html that will be used to extract data. + """ + return self._connection.get('profile/edit').text + + def getName(self): + """Returns two-tuple: (first, last) name. + """ + first = self.firstname_regexp.search(self._html).group(1) + last = self.lastname_regexp.search(self._html).group(1) + return (first, last) + + def getTags(self): + """Returns tags user had selected when describing him/her-self. + """ + guid = self._connection.getUserData()['guid'] + html = self._connection.get('people/{0}'.format(guid)).text + description_regexp = re.compile('#.*?') + return [tag.lower() for tag in re.findall(description_regexp, html)] + + def getBio(self): + """Returns user bio. + """ + bio = self.bio_regexp.search(self._html).group(1) + return bio + + def getLocation(self): + """Returns location string. + """ + location = self.location_regexp.search(self._html).group(1) + return location + + def getGender(self): + """Returns location string. + """ + gender = self.gender_regexp.search(self._html).group(1) + return gender + + def getBirthDate(self, named_month=False): + """Returns three-tuple: (year, month, day). + + :param named_month: if True, return name of the month instead of integer + :type named_month: bool + """ + year = self.birth_year_regexp.search(self._html) + if year is None: year = -1 + else: year = int(year.group(1)) + month = self.birth_month_regexp.search(self._html) + if month is None: + if named_month: month = '' + else: month = -1 + else: + if named_month: + month = month.group(2) + else: + month = int(month.group(1)) + day = self.birth_day_regexp.search(self._html) + if day is None: day = -1 + else: day = int(day.group(1)) + return (year, month, day) + + def isSearchable(self): + """Returns True if profile is searchable. + """ + searchable = self.is_searchable_regexp.search(self._html) + # this is because value="true" in every case so we just + # check if the field is "checked" + if searchable is None: searchable = False # if it isn't - the regexp just won't match + else: searchable = True + return searchable + + def isNSFW(self): + """Returns True if profile is marked as NSFW. + """ + nsfw = self.is_nsfw_regexp.search(self._html) + if nsfw is None: nsfw = False + else: nsfw = True + return nsfw + + def setName(self, first, last): + """Set first and last name. + """ + self.data['profile[first_name]'] = first + self.data['profile[last_name]'] = last + + def setTags(self, tags): + """Sets tags that describe the user. + """ + self.data['tags'] = ', '.join(['#{}'.format(tag) for tag in tags]) + + def setBio(self, bio): + """Set bio of a user. + """ + self.data['profile[bio]'] = bio + + def setLocation(self, location): + """Set location of a user. + """ + self.data['profile[location]'] = location + + def setGender(self, gender): + """Set gender of a user. + """ + self.data['profile[gender]'] = gender + + def setBirthDate(self, year, month, day): + """Set birth date of a user. + """ + self.data['profile[date][year]'] = year + self.data['profile[date][month]'] = month + self.data['profile[date][day]'] = day + + def setSearchable(self, searchable): + """Set user's searchable status. + """ + self.data['profile[searchable]'] = json.dumps(searchable) + + def setNSFW(self, nsfw): + """Set user NSFW status. + """ + self.data['profile[nsfw]'] = json.dumps(nsfw) + + def load(self): + """Loads profile data into self.data dictionary. + **Notice:** Not all keys are loaded yet. + """ + self.setName(*self.getName()) + self.setBio(self.getBio()) + self.setLocation(self.getLocation()) + self.setGender(self.getGender()) + self.setBirthDate(*self.getBirthDate(named_month=False)) + self.setSearchable(self.isSearchable()) + self.setNSFW(self.isNSFW()) + self.setTags(self.getTags()) + self._loaded = True + + def update(self): + """Updates profile information. + """ + if not self._loaded: raise errors.DiaspyError('profile was not loaded') + self.data['authenticity_token'] = repr(self._connection) + print(self.data) + request = self._connection.post('profile', data=self.data, allow_redirects=False) + return request.status_code class Services(): - """Provides interface to services settings. - """ - def __init__(self, connection): - self._connection = connection + """Provides interface to services settings. + """ + def __init__(self, connection): + self._connection = connection diff --git a/diaspy/streams.py b/diaspy/streams.py index c9ca429..b00139c 100644 --- a/diaspy/streams.py +++ b/diaspy/streams.py @@ -20,521 +20,521 @@ We need this to get a UTC timestamp from the latest loaded post in the stream, so we can use it for the more() function. """ try: - import dateutil.parser - def parse_utc_timestamp(date_str): - return round(dateutil.parser.parse(date_str).timestamp()) + import dateutil.parser + def parse_utc_timestamp(date_str): + return round(dateutil.parser.parse(date_str).timestamp()) except ImportError: - try: - from datetime import datetime - from pytz import timezone - def parse_utc_timestamp(date_str): - return round(datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone('UTC')).timestamp()) + try: + from datetime import datetime + from pytz import timezone + def parse_utc_timestamp(date_str): + return round(datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone('UTC')).timestamp()) - except ImportError: - print("Please install either python-dateutil or python-pytz") - exit # TODO raise exception + except ImportError: + print("Please install either python-dateutil or python-pytz") + exit # TODO raise exception class Generic(): - """Object representing generic stream. - """ - _location = 'stream.json' - - def __init__(self, connection, location='', fetch=True): - """ - :param connection: Connection() object - :type connection: diaspy.connection.Connection - :param location: location of json (optional) - :type location: str - :param fetch: will call .fill() if true - :type fetch: bool - """ - self._connection = connection - if location: self._location = location - self.latest = None - self._stream = [] - # since epoch - self.max_time = int(time.mktime(time.gmtime())) - if fetch: self.fill() - - def __contains__(self, post): - """Returns True if stream contains given post. - """ - return post in self._stream - - def __iter__(self): - """Provides iterable interface for stream. - """ - return iter(self._stream) - - def __getitem__(self, n): - """Returns n-th item in Stream. - """ - return self._stream[n] - - def __len__(self): - """Returns length of the Stream. - """ - return len(self._stream) - - def _obtain(self, max_time=0, suppress=True): - """Obtains stream from pod. - - suppress:bool - suppress post-fetching errors (e.g. 404) - """ - params = {} - if max_time: - if self.latest == None: - self.latest = int(time.mktime(time.gmtime()) * 1000) - self.latest -= max_time - else: self.latest += 1 - params['max_time'] = max_time - params['_'] = self.latest - print("Diaspy _obtain.params: {}".format(params)) - request = self._connection.get(self._location, params=params) - if request.status_code != 200: - raise errors.StreamError('wrong status code: {0}'.format(request.status_code)) - posts = [] - latest_time = None # Used to get the created_at from the latest posts we received. - for post in request.json(): - try: - comments = False - if post['interactions']['comments_count'] > 3: comments = True - posts.append(Post(self._connection, id=post['id'], guid=post['guid'], fetch=False, comments=comments, post_data=post)) - if post['created_at']: latest_time = post['created_at'] - except errors.PostError: - if not suppress: - raise - if latest_time: - self.max_time = parse_utc_timestamp( latest_time ) - return posts - - def _expand(self, new_stream): - """Appends older posts to stream. - """ - guids = [post.guid for post in self._stream] - stream = self._stream - for post in new_stream: - if post.guid not in guids: - stream.append(post) - guids.append(post.guid) - self._stream = stream - - def _update(self, new_stream): - """Updates stream with new posts. - """ - guids = [post.guid for post in self._stream] - - stream = self._stream - for i in range(len(new_stream)): - if new_stream[-i].guid not in guids: - stream = [new_stream[-i]] + stream - guids.append(new_stream[-i].guid) - self._stream = stream - - def clear(self): - """Set stream to empty. - """ - self._stream = [] - - def purge(self): - """Removes all unexistent posts from stream. - """ - stream = [] - for post in self._stream: - deleted = False - try: - # error will tell us that the post has been deleted - post.update() - except Exception: - deleted = True - finally: - if not deleted: stream.append(post) - self._stream = stream - - def update(self): - """Updates stream with new posts. - """ - self._update(self._obtain()) - - def fill(self): - """Fills the stream with posts. - - **Notice:** this will create entirely new list of posts. - If you want to preseve posts already present in stream use update(). - """ - self._stream = self._obtain() - - def more(self, max_time=0, backtime=86400): - """Tries to download more (older posts) posts from Stream. - - TODO backtime isn't used anymore. - Diaspora reference: https://github.com/diaspora/diaspora/blob/26a9e50ef935628c800f9a21d345057556fa5c31/app/helpers/stream_helper.rb#L48 - - :param backtime: how many seconds substract each time (defaults to one day) - :type backtime: int - :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own) - :type max_time: int - """ - - if not max_time: max_time = self.max_time - self.max_time = max_time - new_stream = self._obtain(max_time=max_time) - self._expand(new_stream) - - def full(self, backtime=86400, retry=42, callback=None): - """Fetches full stream - containing all posts. - WARNING: this is a **VERY** long running function. - Use callback parameter to access information about the stream during its - run. - - Default backtime is one day. But sometimes user might not have any activity for longer - period (in the beginning of my D* activity I was posting once a month or so). - The role of retry is to hadle such situations by trying to go further back in time. - If a post is found the counter is restored. - - Default retry is 42. If you don't know why go to the nearest library (or to the nearest - Piratebay mirror) and grab a copy of "A Hitchhiker's Guide to the Galaxy" and read the - book to find out. This will also increase your level of geekiness and you'll have a - great time reading the book. - - :param backtime: how many seconds to substract each time - :type backtime: int - :param retry: how many times the functin should look deeper than your last post - :type retry: int - :param callback: callable taking diaspy.streams.Generic as an argument - :returns: integer, lenght of the stream - """ - oldstream = self.copy() - self.more() - while len(oldstream) < len(self): - oldstream = self.copy() - if callback is not None: callback(self) - self.more(backtime=backtime) - if len(oldstream) < len(self): continue - # but if no posts were found start retrying... - print('retrying... {0}'.format(retry)) - n = retry - while n > 0: - print('\t', n, self.max_time) - # try to get even more posts... - self.more(backtime=backtime) - print('\t', len(oldstream), len(self)) - # check if it was a success... - if len(oldstream) < len(self): - # and if so restore normal order of execution by - # going one loop higher - break - oldstream = self.copy() - # if it was not a success substract one backtime, keep calm and - # try going further back in time... - n -= 1 - # check the comment below - # no commented code should be present in good software - #if len(oldstream) == len(self): break - return len(self) - - def copy(self): - """Returns copy (list of posts) of current stream. - """ - return [p for p in self._stream] - - def json(self, comments=False, **kwargs): - """Returns JSON encoded string containing stream's data. - - :param comments: to include comments or not to include 'em, that is the question this param holds answer to - :type comments: bool - """ - stream = [post for post in self._stream] - if comments: - for i, post in enumerate(stream): - post._fetchcomments() - comments = [c.data for c in post.comments] - post['interactions']['comments'] = comments - stream[i] = post - stream = [post._data for post in stream] - return json.dumps(stream, **kwargs) + """Object representing generic stream. + """ + _location = 'stream.json' + + def __init__(self, connection, location='', fetch=True): + """ + :param connection: Connection() object + :type connection: diaspy.connection.Connection + :param location: location of json (optional) + :type location: str + :param fetch: will call .fill() if true + :type fetch: bool + """ + self._connection = connection + if location: self._location = location + self.latest = None + self._stream = [] + # since epoch + self.max_time = int(time.mktime(time.gmtime())) + if fetch: self.fill() + + def __contains__(self, post): + """Returns True if stream contains given post. + """ + return post in self._stream + + def __iter__(self): + """Provides iterable interface for stream. + """ + return iter(self._stream) + + def __getitem__(self, n): + """Returns n-th item in Stream. + """ + return self._stream[n] + + def __len__(self): + """Returns length of the Stream. + """ + return len(self._stream) + + def _obtain(self, max_time=0, suppress=True): + """Obtains stream from pod. + + suppress:bool - suppress post-fetching errors (e.g. 404) + """ + params = {} + if max_time: + if self.latest == None: + self.latest = int(time.mktime(time.gmtime()) * 1000) + self.latest -= max_time + else: self.latest += 1 + params['max_time'] = max_time + params['_'] = self.latest + print("Diaspy _obtain.params: {}".format(params)) + request = self._connection.get(self._location, params=params) + if request.status_code != 200: + raise errors.StreamError('wrong status code: {0}'.format(request.status_code)) + posts = [] + latest_time = None # Used to get the created_at from the latest posts we received. + for post in request.json(): + try: + comments = False + if post['interactions']['comments_count'] > 3: comments = True + posts.append(Post(self._connection, id=post['id'], guid=post['guid'], fetch=False, comments=comments, post_data=post)) + if post['created_at']: latest_time = post['created_at'] + except errors.PostError: + if not suppress: + raise + if latest_time: + self.max_time = parse_utc_timestamp( latest_time ) + return posts + + def _expand(self, new_stream): + """Appends older posts to stream. + """ + guids = [post.guid for post in self._stream] + stream = self._stream + for post in new_stream: + if post.guid not in guids: + stream.append(post) + guids.append(post.guid) + self._stream = stream + + def _update(self, new_stream): + """Updates stream with new posts. + """ + guids = [post.guid for post in self._stream] + + stream = self._stream + for i in range(len(new_stream)): + if new_stream[-i].guid not in guids: + stream = [new_stream[-i]] + stream + guids.append(new_stream[-i].guid) + self._stream = stream + + def clear(self): + """Set stream to empty. + """ + self._stream = [] + + def purge(self): + """Removes all unexistent posts from stream. + """ + stream = [] + for post in self._stream: + deleted = False + try: + # error will tell us that the post has been deleted + post.update() + except Exception: + deleted = True + finally: + if not deleted: stream.append(post) + self._stream = stream + + def update(self): + """Updates stream with new posts. + """ + self._update(self._obtain()) + + def fill(self): + """Fills the stream with posts. + + **Notice:** this will create entirely new list of posts. + If you want to preseve posts already present in stream use update(). + """ + self._stream = self._obtain() + + def more(self, max_time=0, backtime=86400): + """Tries to download more (older posts) posts from Stream. + + TODO backtime isn't used anymore. + Diaspora reference: https://github.com/diaspora/diaspora/blob/26a9e50ef935628c800f9a21d345057556fa5c31/app/helpers/stream_helper.rb#L48 + + :param backtime: how many seconds substract each time (defaults to one day) + :type backtime: int + :param max_time: seconds since epoch (optional, diaspy'll figure everything on its own) + :type max_time: int + """ + + if not max_time: max_time = self.max_time + self.max_time = max_time + new_stream = self._obtain(max_time=max_time) + self._expand(new_stream) + + def full(self, backtime=86400, retry=42, callback=None): + """Fetches full stream - containing all posts. + WARNING: this is a **VERY** long running function. + Use callback parameter to access information about the stream during its + run. + + Default backtime is one day. But sometimes user might not have any activity for longer + period (in the beginning of my D* activity I was posting once a month or so). + The role of retry is to hadle such situations by trying to go further back in time. + If a post is found the counter is restored. + + Default retry is 42. If you don't know why go to the nearest library (or to the nearest + Piratebay mirror) and grab a copy of "A Hitchhiker's Guide to the Galaxy" and read the + book to find out. This will also increase your level of geekiness and you'll have a + great time reading the book. + + :param backtime: how many seconds to substract each time + :type backtime: int + :param retry: how many times the functin should look deeper than your last post + :type retry: int + :param callback: callable taking diaspy.streams.Generic as an argument + :returns: integer, lenght of the stream + """ + oldstream = self.copy() + self.more() + while len(oldstream) < len(self): + oldstream = self.copy() + if callback is not None: callback(self) + self.more(backtime=backtime) + if len(oldstream) < len(self): continue + # but if no posts were found start retrying... + print('retrying... {0}'.format(retry)) + n = retry + while n > 0: + print('\t', n, self.max_time) + # try to get even more posts... + self.more(backtime=backtime) + print('\t', len(oldstream), len(self)) + # check if it was a success... + if len(oldstream) < len(self): + # and if so restore normal order of execution by + # going one loop higher + break + oldstream = self.copy() + # if it was not a success substract one backtime, keep calm and + # try going further back in time... + n -= 1 + # check the comment below + # no commented code should be present in good software + #if len(oldstream) == len(self): break + return len(self) + + def copy(self): + """Returns copy (list of posts) of current stream. + """ + return [p for p in self._stream] + + def json(self, comments=False, **kwargs): + """Returns JSON encoded string containing stream's data. + + :param comments: to include comments or not to include 'em, that is the question this param holds answer to + :type comments: bool + """ + stream = [post for post in self._stream] + if comments: + for i, post in enumerate(stream): + post._fetchcomments() + comments = [c.data for c in post.comments] + post['interactions']['comments'] = comments + stream[i] = post + stream = [post._data for post in stream] + return json.dumps(stream, **kwargs) class Outer(Generic): - """Object used by diaspy.models.User to represent - stream of other user. - """ - def __init__(self, connection, guid, fetch=True): - location = 'people/{}/stream.json'.format(guid) - super().__init__(connection, location, fetch) + """Object used by diaspy.models.User to represent + stream of other user. + """ + def __init__(self, connection, guid, fetch=True): + location = 'people/{}/stream.json'.format(guid) + super().__init__(connection, location, fetch) class Stream(Generic): - """The main stream containing the combined posts of the - followed users and tags and the community spotlights posts - if the user enabled those. - """ - location = 'stream.json' - - def post(self, text='', aspect_ids='public', photos=None, photo='', poll_question=None, poll_answers=None, location_coords=None, provider_display_name=''): - """This function sends a post to an aspect. - If both `photo` and `photos` are specified `photos` takes precedence. - - :param text: Text to post. - :type text: str - - :param aspect_ids: Aspect ids to send post to. - :type aspect_ids: str - - :param photo: filename of photo to post - :type photo: str - - :param photos: id of photo to post (obtained from _photoupload()) - :type photos: int - - :param provider_display_name: name of provider displayed under the post - :type provider_display_name: str - - :param poll_question: Question string - :type poll_question: str - - :param poll_answers: Anwsers to the poll - :type poll_answers: list with strings - - :param location_coords: TODO - :type location_coords: TODO - - :returns: diaspy.models.Post -- the Post which has been created - """ - data = {} - data['aspect_ids'] = aspect_ids - data['status_message'] = {'text': text, 'provider_display_name': provider_display_name} - if photo: data['photos'] = self._photoupload(photo) - if photos: data['photos'] = photos - if poll_question and poll_answers: - data['poll_question'] = poll_question - data['poll_answers'] = poll_answers - if location_coords: data['location_coords'] = location_coords - - request = self._connection.post('status_messages', - data=json.dumps(data), - headers={'content-type': 'application/json', - 'accept': 'application/json', - 'x-csrf-token': repr(self._connection)}) - if request.status_code != 201: - raise Exception('{0}: Post could not be posted.'.format(request.status_code)) - post_json = request.json() - post = Post(self._connection, id=post_json['id'], guid=post_json['guid'], post_data=post_json) - return post - - def _photoupload(self, filename, aspects=[]): - """Uploads picture to the pod. - - :param filename: path to picture file - :type filename: str - :param aspect_ids: list of ids of aspects to which you want to upload this photo - :type aspect_ids: list of integers - - :returns: id of the photo being uploaded - """ - data = open(filename, 'rb') - image = data.read() - data.close() - - params = {} - params['photo[pending]'] = 'true' - params['set_profile_image'] = '' - params['qqfile'] = filename - if not aspects: aspects = self._connection.getUserData()['aspects'] - for i, aspect in enumerate(aspects): - params['photo[aspect_ids][{0}]'.format(i)] = aspect['id'] - - headers = {'content-type': 'application/octet-stream', - 'x-csrf-token': repr(self._connection), - 'x-file-name': filename} - - request = self._connection.post('photos', data=image, params=params, headers=headers) - if request.status_code != 200: - raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code)) - return request.json()['data']['photo']['id'] + """The main stream containing the combined posts of the + followed users and tags and the community spotlights posts + if the user enabled those. + """ + location = 'stream.json' + + def post(self, text='', aspect_ids='public', photos=None, photo='', poll_question=None, poll_answers=None, location_coords=None, provider_display_name=''): + """This function sends a post to an aspect. + If both `photo` and `photos` are specified `photos` takes precedence. + + :param text: Text to post. + :type text: str + + :param aspect_ids: Aspect ids to send post to. + :type aspect_ids: str + + :param photo: filename of photo to post + :type photo: str + + :param photos: id of photo to post (obtained from _photoupload()) + :type photos: int + + :param provider_display_name: name of provider displayed under the post + :type provider_display_name: str + + :param poll_question: Question string + :type poll_question: str + + :param poll_answers: Anwsers to the poll + :type poll_answers: list with strings + + :param location_coords: TODO + :type location_coords: TODO + + :returns: diaspy.models.Post -- the Post which has been created + """ + data = {} + data['aspect_ids'] = aspect_ids + data['status_message'] = {'text': text, 'provider_display_name': provider_display_name} + if photo: data['photos'] = self._photoupload(photo) + if photos: data['photos'] = photos + if poll_question and poll_answers: + data['poll_question'] = poll_question + data['poll_answers'] = poll_answers + if location_coords: data['location_coords'] = location_coords + + request = self._connection.post('status_messages', + data=json.dumps(data), + headers={'content-type': 'application/json', + 'accept': 'application/json', + 'x-csrf-token': repr(self._connection)}) + if request.status_code != 201: + raise Exception('{0}: Post could not be posted.'.format(request.status_code)) + post_json = request.json() + post = Post(self._connection, id=post_json['id'], guid=post_json['guid'], post_data=post_json) + return post + + def _photoupload(self, filename, aspects=[]): + """Uploads picture to the pod. + + :param filename: path to picture file + :type filename: str + :param aspect_ids: list of ids of aspects to which you want to upload this photo + :type aspect_ids: list of integers + + :returns: id of the photo being uploaded + """ + data = open(filename, 'rb') + image = data.read() + data.close() + + params = {} + params['photo[pending]'] = 'true' + params['set_profile_image'] = '' + params['qqfile'] = filename + if not aspects: aspects = self._connection.getUserData()['aspects'] + for i, aspect in enumerate(aspects): + params['photo[aspect_ids][{0}]'.format(i)] = aspect['id'] + + headers = {'content-type': 'application/octet-stream', + 'x-csrf-token': repr(self._connection), + 'x-file-name': filename} + + request = self._connection.post('photos', data=image, params=params, headers=headers) + if request.status_code != 200: + raise errors.StreamError('photo cannot be uploaded: {0}'.format(request.status_code)) + return request.json()['data']['photo']['id'] class Activity(Stream): - """Stream representing user's activity. - """ - _location = 'activity.json' - - def _delid(self, id): - """Deletes post with given id. - """ - post = None - for p in self._stream: - if p['id'] == id: - post = p - break - if post is not None: post.delete() - - def delete(self, post): - """Deletes post from users activity. - `post` can be either post id or Post() - object which will be identified and deleted. - After deleting post the stream will be purged. - - :param post: post identifier - :type post: str, diaspy.models.Post - """ - if type(post) == str: self._delid(post) - elif type(post) == Post: post.delete() - else: raise TypeError('this method accepts str or Post types: {0} given') - self.purge() + """Stream representing user's activity. + """ + _location = 'activity.json' + + def _delid(self, id): + """Deletes post with given id. + """ + post = None + for p in self._stream: + if p['id'] == id: + post = p + break + if post is not None: post.delete() + + def delete(self, post): + """Deletes post from users activity. + `post` can be either post id or Post() + object which will be identified and deleted. + After deleting post the stream will be purged. + + :param post: post identifier + :type post: str, diaspy.models.Post + """ + if type(post) == str: self._delid(post) + elif type(post) == Post: post.delete() + else: raise TypeError('this method accepts str or Post types: {0} given') + self.purge() class Aspects(Generic): - """This stream contains the posts filtered by - the specified aspect IDs. You can choose the aspect IDs with - the parameter `aspect_ids` which value should be - a comma seperated list of aspect IDs. - If the parameter is ommitted all aspects are assumed. - An example call would be `aspects.json?aspect_ids=23,5,42` - """ - _location = 'aspects.json' - - def getAspectID(self, aspect_name): - """Returns id of an aspect of given name. - Returns -1 if aspect is not found. - - :param aspect_name: aspect name (must be spelled exactly as when created) - :type aspect_name: str - :returns: int - """ - id = -1 - aspects = self._connection.getUserData()['aspects'] - for aspect in aspects: - if aspect['name'] == aspect_name: id = aspect['id'] - return id - - def filter(self, ids): - """Filters posts by given aspect ids. - - :parameter ids: list of apsect ids - :type ids: list of integers - """ - self._location = 'aspects.json?a_ids[]=' + '{}'.format('&a_ids[]='.join(ids)) - self.fill() # this will create entirely new list of posts. - - def add(self, aspect_name, visible=0): - """This function adds a new aspect. - Status code 422 is accepted because it is returned by D* when - you try to add aspect already present on your aspect list. - - :param aspect_name: name of aspect to create - :param visible: whether the contacts in this aspect are visible to each other or not - - :returns: Aspect() object of just created aspect - """ - data = {'authenticity_token': repr(self._connection), - 'aspect[name]': aspect_name, - 'aspect[contacts_visible]': visible} - - request = self._connection.post('aspects', data=data) - if request.status_code not in [200, 422]: - raise Exception('wrong status code: {0}'.format(request.status_code)) - - id = self.getAspectID(aspect_name) - return Aspect(self._connection, id) - - def remove(self, id=-1, name=''): - """This method removes an aspect. - You can give it either id or name of the aspect. - When both are specified, id takes precedence over name. - - Status code 500 is accepted because although the D* will - go nuts it will remove the aspect anyway. - - :param aspect_id: id fo aspect to remove - :type aspect_id: int - :param name: name of aspect to remove - :type name: str - """ - if id == -1 and name: id = self.getAspectID(name) - data = {'_method': 'delete', - 'authenticity_token': repr(self._connection)} - request = self._connection.post('aspects/{0}'.format(id), data=data) - if request.status_code not in [200, 302, 500]: - raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code)) + """This stream contains the posts filtered by + the specified aspect IDs. You can choose the aspect IDs with + the parameter `aspect_ids` which value should be + a comma seperated list of aspect IDs. + If the parameter is ommitted all aspects are assumed. + An example call would be `aspects.json?aspect_ids=23,5,42` + """ + _location = 'aspects.json' + + def getAspectID(self, aspect_name): + """Returns id of an aspect of given name. + Returns -1 if aspect is not found. + + :param aspect_name: aspect name (must be spelled exactly as when created) + :type aspect_name: str + :returns: int + """ + id = -1 + aspects = self._connection.getUserData()['aspects'] + for aspect in aspects: + if aspect['name'] == aspect_name: id = aspect['id'] + return id + + def filter(self, ids): + """Filters posts by given aspect ids. + + :parameter ids: list of apsect ids + :type ids: list of integers + """ + self._location = 'aspects.json?a_ids[]=' + '{}'.format('&a_ids[]='.join(ids)) + self.fill() # this will create entirely new list of posts. + + def add(self, aspect_name, visible=0): + """This function adds a new aspect. + Status code 422 is accepted because it is returned by D* when + you try to add aspect already present on your aspect list. + + :param aspect_name: name of aspect to create + :param visible: whether the contacts in this aspect are visible to each other or not + + :returns: Aspect() object of just created aspect + """ + data = {'authenticity_token': repr(self._connection), + 'aspect[name]': aspect_name, + 'aspect[contacts_visible]': visible} + + request = self._connection.post('aspects', data=data) + if request.status_code not in [200, 422]: + raise Exception('wrong status code: {0}'.format(request.status_code)) + + id = self.getAspectID(aspect_name) + return Aspect(self._connection, id) + + def remove(self, id=-1, name=''): + """This method removes an aspect. + You can give it either id or name of the aspect. + When both are specified, id takes precedence over name. + + Status code 500 is accepted because although the D* will + go nuts it will remove the aspect anyway. + + :param aspect_id: id fo aspect to remove + :type aspect_id: int + :param name: name of aspect to remove + :type name: str + """ + if id == -1 and name: id = self.getAspectID(name) + data = {'_method': 'delete', + 'authenticity_token': repr(self._connection)} + request = self._connection.post('aspects/{0}'.format(id), data=data) + if request.status_code not in [200, 302, 500]: + raise Exception('wrong status code: {0}: cannot remove aspect'.format(request.status_code)) class Commented(Generic): - """This stream contains all posts - the user has made a comment on. - """ - _location = 'commented.json' + """This stream contains all posts + the user has made a comment on. + """ + _location = 'commented.json' class Liked(Generic): - """This stream contains all posts the user liked. - """ - _location = 'liked.json' + """This stream contains all posts the user liked. + """ + _location = 'liked.json' class Mentions(Generic): - """This stream contains all posts - the user is mentioned in. - """ - _location = 'mentions.json' + """This stream contains all posts + the user is mentioned in. + """ + _location = 'mentions.json' class FollowedTags(Generic): - """This stream contains all posts - containing tags the user is following. - """ - _location = 'followed_tags.json' - - def get(self): - """Returns list of followed tags. - """ - return [] - - def remove(self, tag_id): - """Stop following a tag. - - :param tag_id: tag id - :type tag_id: int - """ - data = {'authenticity_token': self._connection.get_token()} - request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data) - if request.status_code != 404: - raise Exception('wrong status code: {0}'.format(request.status_code)) - - def add(self, tag_name): - """Follow new tag. - Error code 403 is accepted because pods respod with it when request - is sent to follow a tag that a user already follows. - - :param tag_name: tag name - :type tag_name: str - :returns: int (response code) - """ - data = {'name': tag_name, - 'authenticity_token': repr(self._connection), - } - headers = {'content-type': 'application/json', - 'x-csrf-token': repr(self._connection), - 'accept': 'application/json' - } - - request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers) - - if request.status_code not in [201, 403]: - raise Exception('wrong error code: {0}'.format(request.status_code)) - return request.status_code + """This stream contains all posts + containing tags the user is following. + """ + _location = 'followed_tags.json' + + def get(self): + """Returns list of followed tags. + """ + return [] + + def remove(self, tag_id): + """Stop following a tag. + + :param tag_id: tag id + :type tag_id: int + """ + data = {'authenticity_token': self._connection.get_token()} + request = self._connection.delete('tag_followings/{0}'.format(tag_id), data=data) + if request.status_code != 404: + raise Exception('wrong status code: {0}'.format(request.status_code)) + + def add(self, tag_name): + """Follow new tag. + Error code 403 is accepted because pods respod with it when request + is sent to follow a tag that a user already follows. + + :param tag_name: tag name + :type tag_name: str + :returns: int (response code) + """ + data = {'name': tag_name, + 'authenticity_token': repr(self._connection), + } + headers = {'content-type': 'application/json', + 'x-csrf-token': repr(self._connection), + 'accept': 'application/json' + } + + request = self._connection.post('tag_followings', data=json.dumps(data), headers=headers) + + if request.status_code not in [201, 403]: + raise Exception('wrong error code: {0}'.format(request.status_code)) + return request.status_code class Tag(Generic): - """This stream contains all posts containing a tag. - """ - def __init__(self, connection, tag, fetch=True): - """ - :param connection: Connection() object - :type connection: diaspy.connection.Connection - :param tag: tag name - :type tag: str - """ - self._connection = connection - self._location = 'tags/{0}.json'.format(tag) - if fetch: self.fill() + """This stream contains all posts containing a tag. + """ + def __init__(self, connection, tag, fetch=True): + """ + :param connection: Connection() object + :type connection: diaspy.connection.Connection + :param tag: tag name + :type tag: str + """ + self._connection = connection + self._location = 'tags/{0}.json'.format(tag) + if fetch: self.fill()