def create_category(self, name, color, text_color='FFFFFF', permissions=None, parent=None, **kwargs): """ permissions - dict of 'everyone', 'admins', 'moderators', 'staff' with values of """ kwargs['name'] = name kwargs['color'] = color kwargs['text_color'] = text_color if permissions is None and 'permissions' not in kwargs: permissions = {'everyone': '1'} for key, value in permissions.items(): kwargs['permissions[{0}]'.format(key)] = value if parent: parent_id = None for category in self.categories(): if category['name'] == parent: parent_id = category['id'] continue if not parent_id: raise DiscourseClientError(u'{0} not found'.format(parent)) kwargs['parent_category_id'] = parent_id return self._post('/categories', **kwargs)
def _request(self, verb, path, params): params['api_key'] = self.api_key if 'api_username' not in params: params['api_username'] = self.api_username url = self.host + path response = requests.request(verb, url, allow_redirects=False, params=params, timeout=self.timeout) log.debug('response %s: %s', response.status_code, repr(response.text)) if not response.ok: try: msg = u','.join(response.json()['errors']) except (ValueError, TypeError, KeyError): if response.reason: msg = response.reason else: msg = u'{0}: {1}'.format(response.status_code, response.text) if 400 <= response.status_code < 500: raise DiscourseClientError(msg, response=response) raise DiscourseServerError(msg, response=response) if response.status_code == 302: raise DiscourseError( 'Unexpected Redirect, invalid api key or host?', response=response) json_content = 'application/json; charset=utf-8' content_type = response.headers['content-type'] if content_type != json_content: # some calls return empty html documents if response.content == ' ': return None raise DiscourseError( 'Invalid Response, expecting "{0}" got "{1}"'.format( json_content, content_type), response=response) try: decoded = response.json() except ValueError: raise DiscourseError('failed to decode response', response=response) if 'errors' in decoded: message = decoded.get('message') if not message: message = u','.join(decoded['errors']) raise DiscourseError(message, response=response) return decoded
def create_category(self, name, color, text_color="FFFFFF", permissions=None, parent=None, **kwargs): """ Args: name: color: text_color: hex color without number symbol permissions: dict of 'everyone', 'admins', 'moderators', 'staff' with values of ??? parent: name of the category parent_category_id: **kwargs: Returns: """ kwargs["name"] = name kwargs["color"] = color kwargs["text_color"] = text_color if permissions is None and "permissions" not in kwargs: permissions = {"everyone": "1"} for key, value in permissions.items(): kwargs["permissions[{0}]".format(key)] = value if parent: parent_id = None for category in self.categories(): if category["name"] == parent: parent_id = category["id"] continue if not parent_id: raise DiscourseClientError(u"{0} not found".format(parent)) kwargs["parent_category_id"] = parent_id return self._post("/categories", **kwargs)
def _request( self, verb, path, params=None, files=None, data=None, json=None, override_request_kwargs=None ): """ Executes HTTP request to API and handles response Args: verb: HTTP verb as string: GET, DELETE, PUT, POST path: the path on the Discourse API params: dictionary of parameters to include to the API override_request_kwargs: dictionary of requests.request keyword arguments to override defaults Returns: dictionary of response body data or None """ override_request_kwargs = override_request_kwargs or {} url = self.host + path headers = { "Accept": "application/json; charset=utf-8", "Api-Key": self.api_key, "Api-Username": self.api_username, } # How many times should we retry if rate limited retry_count = 4 # Extra time (on top of that required by API) to wait on a retry. retry_backoff = 1 while retry_count > 0: request_kwargs = dict( allow_redirects=False, params=params, files=files, data=data, json=json, headers=headers, timeout=self.timeout, ) request_kwargs.update(override_request_kwargs) response = requests.request(verb, url, **request_kwargs) log.debug("response %s: %s", response.status_code, repr(response.text)) if response.ok: break if not response.ok: try: msg = u",".join(response.json()["errors"]) except (ValueError, TypeError, KeyError): if response.reason: msg = response.reason else: msg = u"{0}: {1}".format(response.status_code, response.text) if 400 <= response.status_code < 500: if 429 == response.status_code: # This codepath relies on wait_seconds from Discourse v2.0.0.beta3 / v1.9.3 or higher. rj = response.json() wait_delay = ( retry_backoff + rj["extras"]["wait_seconds"] ) # how long to back off for. if retry_count > 1: time.sleep(wait_delay) retry_count -= 1 log.info( "We have been rate limited and waited {0} seconds ({1} retries left)".format( wait_delay, retry_count ) ) log.debug("API returned {0}".format(rj)) continue else: raise DiscourseClientError(msg, response=response) # Any other response.ok resulting in False raise DiscourseServerError(msg, response=response) if retry_count == 0: raise DiscourseRateLimitedError( "Number of rate limit retries exceeded. Increase retry_backoff or retry_count", response=response, ) if response.status_code == 302: raise DiscourseError( "Unexpected Redirect, invalid api key or host?", response=response ) json_content = "application/json; charset=utf-8" content_type = response.headers["content-type"] if content_type != json_content: # some calls return empty html documents if not response.content.strip(): return None raise DiscourseError( 'Invalid Response, expecting "{0}" got "{1}"'.format( json_content, content_type ), response=response, ) try: decoded = response.json() except ValueError: raise DiscourseError("failed to decode response", response=response) # Checking "errors" length because # data-explorer (e.g. POST /admin/plugins/explorer/queries/{}/run) # sends an empty errors array if "errors" in decoded and len(decoded["errors"]) > 0: message = decoded.get("message") if not message: message = u",".join(decoded["errors"]) raise DiscourseError(message, response=response) return decoded
class TestSsoSyncRest(BaseTestRest): def setUp(self): super().setUp() self._url = "/sso_sync" self.contributor = self.session.query(User). \ filter(User.username == 'contributor'). \ one() self.session.add(SsoKey( domain=sso_domain, key=sso_key, )) self.contributor_external_id = SsoExternalId( domain=sso_domain, external_id='1', user=self.contributor, ) self.session.add(self.contributor_external_id) self.session.flush() set_discourse_client(None) def test_no_sso_key(self): request_body = { 'external_id': '999', 'email': '*****@*****.**' } body = self.app_post_json(self._url, request_body, status=400).json errors = body.get('errors') self.assertEqual('sso_key', errors[0].get('name')) self.assertEqual('Required', errors[0].get('description')) def test_bad_sso_key(self): request_body = { 'sso_key': 'bad_sso_key', 'external_id': '999', 'email': '*****@*****.**' } body = self.app_post_json(self._url, request_body, status=403).json errors = body.get('errors') self.assertEqual('sso_key', errors[0].get('name')) self.assertEqual('Invalid', errors[0].get('description')) def test_no_external_id(self): request_body = {'sso_key': sso_key} body = self.app_post_json(self._url, request_body, status=400).json errors = body.get('errors') self.assertEqual('external_id', errors[0].get('name')) self.assertEqual('Required', errors[0].get('description')) def test_new_user_no_email(self): request_body = { 'sso_key': sso_key, 'external_id': '999', } body = self.app_post_json(self._url, request_body, status=400).json errors = body.get('errors') self.assertEqual('email', errors[0].get('name')) self.assertEqual('Required', errors[0].get('description')) def test_new_user_existing_username(self): request_body = { 'sso_key': sso_key, 'external_id': '999', 'email': '*****@*****.**', 'username': self.contributor.username, 'lang': 'fr', 'groups': 'group1,group2' } body = self.app_post_json(self._url, request_body, status=400).json errors = body.get('errors') self.assertEqual('already used username', errors[0].get('description')) def test_new_user_existing_forum_username(self): request_body = { 'sso_key': sso_key, 'external_id': '999', 'email': '*****@*****.**', 'username': '******', 'forum_username': self.contributor.forum_username, 'lang': 'fr', 'groups': 'group1,group2' } body = self.app_post_json(self._url, request_body, status=400).json errors = body.get('errors') self.assertEqual('already used forum_username', errors[0].get('description')) def test_new_user_forum_username_too_long(self): request_body = { 'sso_key': sso_key, 'external_id': '999', 'email': '*****@*****.**', 'username': '******', 'forum_username': '******', 'lang': 'fr', 'groups': 'group1,group2' } body = self.app_post_json(self._url, request_body, status=400).json errors = body.get('errors') self.assertEqual('forum_username', errors[0].get('name')) self.assertEqual('Longer than maximum length 25', errors[0].get('description')) @patch('c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock( by_external_id=Mock(side_effect=DiscourseClientError( response=Mock(status_code=404))), sync_sso=Mock(return_value={'id': 555}))) def test_new_user_success(self, discourse_mock): request_body = { 'sso_key': sso_key, 'external_id': '999', 'email': '*****@*****.**', 'username': '******', 'name': 'New User', 'forum_username': '******', 'lang': 'fr', } body = self.app_post_json(self._url, request_body, status=200).json sso_external_id = self.session.query(SsoExternalId). \ filter(SsoExternalId.domain == sso_domain). \ filter(SsoExternalId.external_id == '999'). \ one_or_none() self.assertIsNotNone(sso_external_id) sso_user = sso_external_id.user self.assertEqual('newuser', sso_user.username) self.assertEqual('New User', sso_user.name) self.assertEqual('NewUser', sso_user.forum_username) self.assertEqual(sso_external_id.token, self.token_from_url(body.get('url'))) client = discourse_mock.return_value client.by_external_id.assert_called_with(sso_external_id.user.id) client.sync_sso.assert_called_once_with( sso_secret=self.settings.get('discourse.sso_secret'), name='New User', username='******', email='*****@*****.**', external_id=sso_user.id) def token_from_url(self, url): qs = parse_qs(urlparse(url).query) return qs.get('token')[0] @patch('c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock(by_external_id=Mock(return_value={ 'id': 1, 'username': '******' }))) def test_success_by_email(self, discourse_mock): request_body = { 'sso_key': sso_key, 'external_id': '1', 'email': self.contributor.email } body = self.app_post_json(self._url, request_body, status=200).json sso_external_id = self.session.query(SsoExternalId). \ filter(SsoExternalId.domain == sso_domain). \ filter(SsoExternalId.external_id == '1'). \ one_or_none() self.assertIsNotNone(sso_external_id) self.assertEqual('contributor', sso_external_id.user.username) self.assertEqual(sso_external_id.token, self.token_from_url(body.get('url'))) @patch('c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock(by_external_id=Mock(return_value={ 'id': 1, 'username': '******' }))) def test_success_by_external_id(self, discourse_mock): request_body = { 'sso_key': sso_key, 'external_id': '1', 'email': '*****@*****.**', 'username': self.contributor.username, 'name': self.contributor.name, 'forum_username': self.contributor.forum_username, 'lang': 'fr', } body = self.app_post_json(self._url, request_body, status=200).json self.session.expire(self.contributor_external_id) self.assertEqual(self.contributor_external_id.token, self.token_from_url(body.get('url'))) client = discourse_mock.return_value client.by_external_id.assert_called_once_with(self.contributor.id) @patch( 'c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock(by_external_id=Mock(side_effect=ConnectionError()))) def test_discourse_down(self, discourse_mock): request_body = { 'sso_key': sso_key, 'external_id': '999', 'email': '*****@*****.**', 'username': '******', 'name': 'New User', 'forum_username': '******', 'lang': 'fr', } body = self.app_post_json(self._url, request_body, status=500).json errors = body.get('errors') self.assertEqual('Error with Discourse', errors[0].get('description')) @patch('c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock( by_external_id=Mock(side_effect=DiscourseClientError( response=Mock(status_code=404))), sync_sso=Mock(return_value={'id': 555}))) def test_new_user_no_name(self, discourse_mock): """name and forum_username should default to username""" request_body = { 'sso_key': sso_key, 'external_id': '999', 'email': '*****@*****.**', 'username': '******', 'lang': 'fr', } self.app_post_json(self._url, request_body, status=200).json sso_external_id = self.session.query(SsoExternalId). \ filter(SsoExternalId.domain == sso_domain). \ filter(SsoExternalId.external_id == '999'). \ one_or_none() self.assertIsNotNone(sso_external_id) sso_user = sso_external_id.user self.assertEqual('newuser', sso_user.username) self.assertEqual('newuser', sso_user.name) self.assertEqual('newuser', sso_user.forum_username) @patch('c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock( by_external_id=Mock(return_value={ 'id': 1, 'username': '******' }), sync_sso=Mock(return_value={'id': 1}), groups=Mock(return_value=[]), )) def test_not_found_group(self, discourse_mock): request_body = { 'sso_key': sso_key, 'external_id': '1', 'group': 'group_1', } self.app_post_json(self._url, request_body, status=200).json @patch('c2corg_api.security.discourse_client.DiscourseClient', return_value=Mock(by_external_id=Mock(return_value={ 'id': 1, 'username': '******' }), groups=Mock(return_value=[{ 'id': 222, 'name': 'group_1' }]), add_user_to_group=Mock())) def test_existing_group(self, discourse_mock): request_body = { 'sso_key': sso_key, 'external_id': '1', 'groups': 'group_1' } self.app_post_json(self._url, request_body, status=200).json client = discourse_mock.return_value client.add_user_to_group.assert_called_once_with(222, 1)
def _request(self, verb, path, params={}, data={}): """ Executes HTTP request to API and handles response Args: verb: HTTP verb as string: GET, DELETE, PUT, POST path: the path on the Discourse API params: dictionary of parameters to include to the API Returns: """ params['api_key'] = self.api_key if 'api_username' not in params: params['api_username'] = self.api_username url = self.host + path headers = {'Accept': 'application/json; charset=utf-8'} response = requests.request(verb, url, allow_redirects=False, params=params, data=data, headers=headers, timeout=self.timeout) log.debug('response %s: %s', response.status_code, repr(response.text)) if not response.ok: try: msg = u','.join(response.json()['errors']) except (ValueError, TypeError, KeyError): if response.reason: msg = response.reason else: msg = u'{0}: {1}'.format(response.status_code, response.text) if 400 <= response.status_code < 500: raise DiscourseClientError(msg, response=response) raise DiscourseServerError(msg, response=response) if response.status_code == 302: raise DiscourseError( 'Unexpected Redirect, invalid api key or host?', response=response) json_content = 'application/json; charset=utf-8' content_type = response.headers['content-type'] if content_type != json_content: # some calls return empty html documents if not response.content.strip(): return None raise DiscourseError( 'Invalid Response, expecting "{0}" got "{1}"'.format( json_content, content_type), response=response) try: decoded = response.json() except ValueError: raise DiscourseError('failed to decode response', response=response) if 'errors' in decoded: message = decoded.get('message') if not message: message = u','.join(decoded['errors']) raise DiscourseError(message, response=response) return decoded
def authenticate_user(self, login, password): resp = self._post('/session', login=login, password=password) if 'error' in resp: raise DiscourseClientError(resp['error']) return resp['user']