diff --git a/alembic_migration/README.md b/alembic_migration/README.md index 88cb4d2c1..9a00b9ac9 100644 --- a/alembic_migration/README.md +++ b/alembic_migration/README.md @@ -7,19 +7,17 @@ Migration scripts are created each time modifications are made on the database m For example if you want to add a new field to a table, add the column in the SQLAlchemy model. -Then auto-generate the migration script with: +Create the migration script with: ``` -.build/venv/bin/alembic revision --autogenerate -m 'Add column x' +.build/venv/bin/alembic revision -m 'Add column x' ``` -A new migration script is created in `alembic_migration/versions/`. Make sure the script looks correct, adjust if necessary. +A new migration script is created in `alembic_migration/versions/`. Add the required database operations to +the script (see [Operation Reference](http://alembic.zzzcomputing.com/en/latest/ops.html)). To add the new column to the database, run the migration (see below) and make sure the database is updated correctly. -Note that not all changes are detected, see [Auto Generating Migrations](http://alembic.zzzcomputing.com/en/latest/autogenerate.html) -for more information. - For *replacable objects* like functions or views, the method described in [documentation](http://alembic.zzzcomputing.com/en/latest/cookbook.html#replaceable-objects) is used. diff --git a/alembic_migration/versions/7e32b653172f_add_column_user_blocked.py b/alembic_migration/versions/7e32b653172f_add_column_user_blocked.py new file mode 100644 index 000000000..6e00a2ddb --- /dev/null +++ b/alembic_migration/versions/7e32b653172f_add_column_user_blocked.py @@ -0,0 +1,28 @@ +"""Add column User.blocked + +Revision ID: 7e32b653172f +Revises: 38df9393c9a9 +Create Date: 2017-01-24 14:53:37.765411 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7e32b653172f' +down_revision = '38df9393c9a9' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'user', + sa.Column( + 'blocked', sa.Boolean(), server_default='false', nullable=False), + schema='users') + + +def downgrade(): + op.drop_column('user', 'blocked', schema='users') diff --git a/c2corg_api/__init__.py b/c2corg_api/__init__.py index 3bd2d136e..778966c43 100644 --- a/c2corg_api/__init__.py +++ b/c2corg_api/__init__.py @@ -4,7 +4,7 @@ from pyramid.config import Configurator from sqlalchemy import engine_from_config, exc, event from sqlalchemy.pool import Pool -from pyramid.httpexceptions import HTTPUnauthorized +from pyramid.httpexceptions import HTTPUnauthorized, HTTPError from c2corg_api.models import ( DBSession, @@ -17,6 +17,7 @@ from c2corg_api.security.roles import is_valid_token, extract_token from pyramid.settings import asbool +from c2corg_api.views import http_error_handler, catch_all_error_handler log = logging.getLogger(__name__) @@ -38,9 +39,8 @@ def jwt_database_validation_tween_factory(handler, registry): """ def tween(request): - # TODO: first set the cookie in request.authorization if needed - # Then forward requests without authorization + # forward requests without authorization if request.authorization is None: # Skipping validation if there is no authorization object. # This is dangerous since a bad ordering of this tween and the @@ -48,14 +48,20 @@ def tween(request): return handler(request) # Finally, check database validation - token = extract_token(request) - valid = token and is_valid_token(token) + try: + token = extract_token(request) + valid = token and is_valid_token(token) + except Exception as exc: + if isinstance(exc, HTTPError): + return http_error_handler(exc, request) + else: + return catch_all_error_handler(exc, request) if valid: return handler(request) else: - # TODO: clear cookie? send json? - return HTTPUnauthorized("Invalid token") + return http_error_handler( + HTTPUnauthorized('Invalid token'), request) return tween diff --git a/c2corg_api/models/user.py b/c2corg_api/models/user.py index 7d71d50c0..563e2534b 100644 --- a/c2corg_api/models/user.py +++ b/c2corg_api/models/user.py @@ -90,6 +90,7 @@ class User(Base): last_modified = Column( DateTime(timezone=True), default=func.now(), onupdate=func.now(), nullable=False, index=True) + blocked = Column(Boolean, nullable=False, default=False) lang = Column( String(2), ForeignKey(schema + '.langs.lang'), diff --git a/c2corg_api/security/discourse_client.py b/c2corg_api/security/discourse_client.py index ecc7adc87..54f1e1502 100644 --- a/c2corg_api/security/discourse_client.py +++ b/c2corg_api/security/discourse_client.py @@ -69,6 +69,14 @@ def logout(self, userid): self.client.log_out(discourse_userid) return discourse_userid + def suspend(self, userid, duration, reason): + discourse_userid = self.get_userid(userid) + return self.client.suspend(discourse_userid, duration, reason) + + def unsuspend(self, userid): + discourse_userid = self.get_userid(userid) + return self.client.unsuspend(discourse_userid) + # Below this: SSO provider def decode_payload(self, payload): decoded = b64decode(payload.encode('utf-8')).decode('utf-8') diff --git a/c2corg_api/security/roles.py b/c2corg_api/security/roles.py index dc7aaae59..0db6153f3 100644 --- a/c2corg_api/security/roles.py +++ b/c2corg_api/security/roles.py @@ -1,3 +1,4 @@ +from pyramid.httpexceptions import HTTPForbidden from pyramid.security import Authenticated from pyramid.interfaces import IAuthenticationPolicy @@ -32,8 +33,19 @@ def groupfinder(userid, request): def is_valid_token(token): now = datetime.datetime.utcnow() - return DBSession.query(Token). \ - filter(Token.value == token, Token.expire > now).count() == 1 + token = DBSession.query(Token). \ + filter(Token.value == token, Token.expire > now).first() + + if not token: + return False + else: + user_is_blocked = DBSession. \ + query(User.blocked). \ + filter(User.id == token.userid). \ + scalar() + if user_is_blocked: + raise HTTPForbidden('account blocked') + return True def add_or_retrieve_token(value, expire, userid): @@ -76,6 +88,10 @@ def log_validated_user_i_know_what_i_do(user, request): See the try_login function for a safe version. """ assert user.email_validated + + if user.blocked: + raise HTTPForbidden('account blocked') + policy = request.registry.queryUtility(IAuthenticationPolicy) now = datetime.datetime.utcnow() exp = now + datetime.timedelta(days=CONST_EXPIRE_AFTER_DAYS) diff --git a/c2corg_api/tests/views/test_user.py b/c2corg_api/tests/views/test_user.py index b9a532ded..be5474247 100644 --- a/c2corg_api/tests/views/test_user.py +++ b/c2corg_api/tests/views/test_user.py @@ -58,7 +58,7 @@ def set_discourse_not_mocked(self): self.set_discourse_client_mock(self.original_discourse_client) def set_discourse_up(self): - # unittest.Mock works great with a completly fake object + # unittest.Mock works great with a completely fake object mock = Mock() mock.redirect_without_nonce = MagicMock() mock.redirect = MagicMock() @@ -340,6 +340,15 @@ def test_forgot_password_discourse_down(self): 'password': 'new pass' }, status=200) + def test_forgot_password_blocked_account(self): + user_id = self.global_userids['contributor'] + user = self.session.query(User).get(user_id) + user.blocked = True + self.session.flush() + + url = '/users/request_password_change' + self.app_post_json(url, {'email': user.email}, status=403) + @attr('jobs') def test_purge_accounts(self): from c2corg_api.jobs.purge_non_activated_accounts import purge_account @@ -425,6 +434,15 @@ def test_login_success_discourse_down(self): body = self.login('moderator', status=200).json self.assertTrue('token' in body) + def test_login_blocked_account(self): + contributor = self.session.query(User).get( + self.global_userids['contributor']) + contributor.blocked = True + self.session.flush() + + body = self.login('contributor', status=403).json + self.assertErrorsContain(body, 'Forbidden', 'account blocked') + def test_login_discourse_success(self): self.set_discourse_not_mocked() # noqa See https://meta.discourse.org/t/official-single-sign-on-for-discourse/13045 diff --git a/c2corg_api/tests/views/test_user_account.py b/c2corg_api/tests/views/test_user_account.py index ad06e52f1..f246fb18c 100644 --- a/c2corg_api/tests/views/test_user_account.py +++ b/c2corg_api/tests/views/test_user_account.py @@ -15,6 +15,15 @@ def test_read_account_info(self): self.assertBodyEqual(body, 'forum_username', 'contributor') self.assertBodyEqual(body, 'is_profile_public', False) + def test_read_account_info_blocked_account(self): + contributor = self.session.query(User).get( + self.global_userids['contributor']) + contributor.blocked = True + self.session.flush() + + body = self.get_json_with_contributor('/users/account', status=403) + self.assertErrorsContain(body, 'Forbidden', 'account blocked') + def _update_account_field_discourse_up(self, field, value): url = '/users/account' currentpassword = self.global_passwords['contributor'] diff --git a/c2corg_api/tests/views/test_user_block.py b/c2corg_api/tests/views/test_user_block.py new file mode 100644 index 000000000..0146571ee --- /dev/null +++ b/c2corg_api/tests/views/test_user_block.py @@ -0,0 +1,251 @@ +from unittest.mock import Mock, MagicMock + +from c2corg_api.models.user import User +from c2corg_api.security.discourse_client import set_discourse_client, \ + APIDiscourseClient +from c2corg_api.tests.views import BaseTestRest + + +class BaseBlockTest(BaseTestRest): + + def setUp(self): # noqa + super(BaseBlockTest, self).setUp() + + self.contributor = self.session.query(User).get( + self.global_userids['contributor']) + self.contributor2 = self.session.query(User).get( + self.global_userids['contributor2']) + self.moderator = self.session.query(User).get( + self.global_userids['moderator']) + + self.contributor2.blocked = True + + self.session.flush() + self.set_discourse_up() + + def set_discourse_client_mock(self, client): + self.discourse_client = client + set_discourse_client(client) + + def set_discourse_not_mocked(self): + self.set_discourse_client_mock(self.original_discourse_client) + + def set_discourse_up(self): + mock = Mock() + mock.get_userid = MagicMock() + mock.suspend = MagicMock() + mock.unsuspend = MagicMock() + self.set_discourse_client_mock(mock) + + def set_discourse_down(self): + mock = APIDiscourseClient(self.settings) + mock.get_userid = MagicMock(side_effect=Exception) + self.set_discourse_client_mock(mock) + + def is_blocked(self, user_id): + user = self.session.query(User).get(user_id) + self.session.refresh(user) + return user.blocked + + +class TestUserBlockRest(BaseBlockTest): + + def setUp(self): # noqa + super(TestUserBlockRest, self).setUp() + self._prefix = '/users/block' + + def test_block_unauthorized(self): + self.app_post_json(self._prefix, {}, status=403) + + headers = self.add_authorization_header(username='contributor') + self.app_post_json(self._prefix, {}, headers=headers, status=403) + + def test_block(self): + request_body = { + 'user_id': self.contributor.id + } + + headers = self.add_authorization_header(username='moderator') + self.app_post_json( + self._prefix, request_body, status=200, headers=headers) + + self.assertTrue(self.is_blocked(self.contributor.id)) + + def test_block_already_blocked_user(self): + """ Test that blocking an already blocked user does not raise an + error. + """ + request_body = { + 'user_id': self.contributor.id + } + + headers = self.add_authorization_header(username='moderator') + self.app_post_json( + self._prefix, request_body, status=200, headers=headers) + self.app_post_json( + self._prefix, request_body, status=200, headers=headers) + + self.assertTrue(self.is_blocked(self.contributor.id)) + + def test_block_discourse_error(self): + self.set_discourse_down() + + request_body = { + 'user_id': self.contributor.id + } + + headers = self.add_authorization_header(username='moderator') + body = self.app_post_json( + self._prefix, request_body, status=500, headers=headers) + self.assertErrorsContain(body.json, 'Internal Server Error') + + self.assertFalse(self.is_blocked(self.contributor.id)) + + def test_block_invalid_user_id(self): + request_body = { + 'user_id': -1 + } + + headers = self.add_authorization_header(username='moderator') + response = self.app_post_json( + self._prefix, request_body, status=400, headers=headers) + + body = response.json + self.assertEqual(body.get('status'), 'error') + errors = body.get('errors') + self.assertIsNotNone(self.get_error(errors, 'user_id')) + + +class TestUserUnblockRest(BaseBlockTest): + + def setUp(self): # noqa + super(TestUserUnblockRest, self).setUp() + self._prefix = '/users/unblock' + + def test_block_unauthorized(self): + self.app_post_json(self._prefix, {}, status=403) + + headers = self.add_authorization_header(username='contributor') + self.app_post_json(self._prefix, {}, headers=headers, status=403) + + def test_unblock(self): + request_body = { + 'user_id': self.contributor2.id + } + self.assertTrue(self.is_blocked(self.contributor2.id)) + + headers = self.add_authorization_header(username='moderator') + self.app_post_json( + self._prefix, request_body, status=200, headers=headers) + self.assertFalse(self.is_blocked(self.contributor2.id)) + + def test_unblock_not_blocked_user(self): + """ Test that unblocking a not blocked user does not raise an error. + """ + request_body = { + 'user_id': self.contributor.id + } + + headers = self.add_authorization_header(username='moderator') + self.app_post_json( + self._prefix, request_body, status=200, headers=headers) + + self.assertFalse(self.is_blocked(self.contributor.id)) + + def test_unblock_invalid_user_id(self): + request_body = { + 'user_id': -1 + } + + headers = self.add_authorization_header(username='moderator') + response = self.app_post_json( + self._prefix, request_body, status=400, headers=headers) + + body = response.json + self.assertEqual(body.get('status'), 'error') + errors = body.get('errors') + self.assertIsNotNone(self.get_error(errors, 'user_id')) + + def test_unblock_discourse_error(self): + self.set_discourse_down() + + request_body = { + 'user_id': self.contributor2.id + } + self.assertTrue(self.is_blocked(self.contributor2.id)) + + headers = self.add_authorization_header(username='moderator') + body = self.app_post_json( + self._prefix, request_body, status=500, headers=headers) + self.assertErrorsContain(body.json, 'Internal Server Error') + + self.assertTrue(self.is_blocked(self.contributor2.id)) + + +class TestUserBlockedRest(BaseBlockTest): + + def setUp(self): # noqa + super(TestUserBlockedRest, self).setUp() + self._prefix = '/users/blocked' + + def test_blocked_unauthorized(self): + self.app.get(self._prefix + '/123', status=403) + + headers = self.add_authorization_header(username='contributor') + self.app.get(self._prefix, headers=headers, status=403) + + def test_blocked(self): + headers = self.add_authorization_header(username='moderator') + response = self.app.get( + self._prefix + '/{}'.format(self.contributor2.id), + status=200, headers=headers) + body = response.json + + self.assertTrue(body['blocked']) + + def test_blocked_not(self): + headers = self.add_authorization_header(username='moderator') + response = self.app.get( + self._prefix + '/{}'.format(self.contributor.id), + status=200, headers=headers) + body = response.json + + self.assertFalse(body['blocked']) + + def test_blocked_invalid_user_id(self): + headers = self.add_authorization_header(username='moderator') + response = self.app.get( + self._prefix + '/invalid-user-id', + status=400, headers=headers) + + body = response.json + self.assertEqual(body.get('status'), 'error') + errors = body.get('errors') + self.assertIsNotNone(self.get_error(errors, 'id')) + + def test_blocked_wrong_user_id(self): + headers = self.add_authorization_header(username='moderator') + self.app.get(self._prefix + '/9999999999', status=400, headers=headers) + + +class TestUserBlockedAllRest(BaseBlockTest): + + def setUp(self): # noqa + super(TestUserBlockedAllRest, self).setUp() + self._prefix = '/users/blocked' + + def test_blocked_unauthenticated(self): + self.app.get(self._prefix, status=403) + + headers = self.add_authorization_header(username='contributor') + self.app.get(self._prefix, headers=headers, status=403) + + def test_blocked(self): + headers = self.add_authorization_header(username='moderator') + response = self.app.get(self._prefix, status=200, headers=headers) + body = response.json + + blocked_users = body['blocked'] + self.assertEqual(1, len(blocked_users)) + self.assertEqual( + self.contributor2.id, blocked_users[0]['document_id']) diff --git a/c2corg_api/views/user.py b/c2corg_api/views/user.py index aac71038b..d28fd8b1f 100644 --- a/c2corg_api/views/user.py +++ b/c2corg_api/views/user.py @@ -23,7 +23,7 @@ from cornice.resource import resource from cornice.validators import colander_body_validator from functools import partial -from pyramid.httpexceptions import HTTPInternalServerError +from pyramid.httpexceptions import HTTPInternalServerError, HTTPForbidden from pyramid.settings import asbool from sqlalchemy.sql.expression import and_, func @@ -225,6 +225,11 @@ def validate_user_from_nonce(purpose, request, **kwargs): path='/users/validate_new_password/{nonce}', cors_policy=cors_policy) class UserValidateNewPasswordRest(object): + """ + This service allows to set a new password in case a user has forgotten + their password. The expected nonce is the one generated by the + `/users/request_password_change` service (see below). + """ def __init__(self, request): self.request = request @@ -281,6 +286,12 @@ def validate_required_user_from_email(request, **kwargs): @resource(path='/users/request_password_change', cors_policy=cors_policy) class UserRequestChangePasswordRest(object): + """ + This web-service is used when a user has forgotten their password. + It will send out an email containing a link to a page on which the + user can enter a new password. The new password is sent to the + web-service `/users/validate_new_password/{nonce}` (see above). + """ def __init__(self, request): self.request = request @@ -288,6 +299,10 @@ def __init__(self, request): def post(self): request = self.request user = request.validated['user'] + + if user.blocked: + raise HTTPForbidden('account blocked') + user.update_validation_nonce( Purpose.new_password, VALIDATION_EXPIRE_DAYS) diff --git a/c2corg_api/views/user_block.py b/c2corg_api/views/user_block.py new file mode 100644 index 000000000..0cdb8d53d --- /dev/null +++ b/c2corg_api/views/user_block.py @@ -0,0 +1,169 @@ +import logging + +from c2corg_api import DBSession +from c2corg_api.models.user import User +from c2corg_api.security.discourse_client import get_discourse_client +from c2corg_api.views import cors_policy, restricted_json_view +from c2corg_api.views.document_listings import get_documents_for_ids +from c2corg_api.views.document_schemas import user_profile_documents_config +from c2corg_api.views.validation import validate_id, \ + validate_body_user_id +from colander import MappingSchema, SchemaNode, Integer, required +from cornice.resource import resource +from cornice.validators import colander_body_validator +from pyramid.httpexceptions import HTTPBadRequest, HTTPInternalServerError + +log = logging.getLogger(__name__) + + +class BlockSchema(MappingSchema): + user_id = SchemaNode(Integer(), missing=required) + + +def _get_user(user_id): + user = DBSession.query(User).get(user_id) + + if not user: + raise HTTPBadRequest('Unknown user {}'.format(user_id)) + + return user + + +@resource(path='/users/block', cors_policy=cors_policy) +class UserBlockRest(object): + + def __init__(self, request): + self.request = request + + @restricted_json_view( + permission='moderator', + schema=BlockSchema(), + validators=[colander_body_validator, validate_body_user_id]) + def post(self): + """ Block the given user. + + Request: + `POST` `/users/block` + + Request body: + {'user_id': @user_id@} + + """ + user = _get_user(self.request.validated['user_id']) + user.blocked = True + + # suspend account in Discourse (suspending an account prevents a login) + try: + client = get_discourse_client(self.request.registry.settings) + block_duration = 99999 # 99999 days = 273 years + client.suspend( + user.id, block_duration, 'account blocked by moderator') + except: + log.error( + 'Suspending account in Discourse failed: %d', user.id, + exc_info=True) + raise HTTPInternalServerError( + 'Suspending account in Discourse failed') + + return {} + + +@resource(path='/users/unblock', cors_policy=cors_policy) +class UserUnblockRest(object): + + def __init__(self, request): + self.request = request + + @restricted_json_view( + permission='moderator', + schema=BlockSchema(), + validators=[colander_body_validator, validate_body_user_id]) + def post(self): + """ Unblock the given user. + + Request: + `POST` `/users/unblock` + + Request body: + {'user_id': @user_id@} + + """ + user = _get_user(self.request.validated['user_id']) + user.blocked = False + + # unsuspend account in Discourse + try: + client = get_discourse_client(self.request.registry.settings) + client.unsuspend(user.id) + except: + log.error( + 'Unsuspending account in Discourse failed: %d', user.id, + exc_info=True) + raise HTTPInternalServerError( + 'Unsuspending account in Discourse failed') + + return {} + + +@resource(path='/users/blocked/{id}', cors_policy=cors_policy) +class UserBlockedRest(object): + + def __init__(self, request): + self.request = request + + @restricted_json_view(permission='moderator', validators=[validate_id]) + def get(self): + """ Check if the given user is blocked. + + Request: + `GET` `users/blocked/{user_id}` + + Example response: + + {'blocked': true} + + """ + user = _get_user(self.request.validated['id']) + + return { + 'blocked': user.blocked + } + + +@resource(path='/users/blocked', cors_policy=cors_policy) +class UserBlockedAllRest(object): + + def __init__(self, request): + self.request = request + + @restricted_json_view(permission='moderator') + def get(self): + """ Get the blocked users. + + Request: + `GET` `/users/blocked` + + Example response: + + { + 'blocked': [ + { + 'document_id': 123, + ... + } + ] + } + """ + blocked_user_ids = DBSession. \ + query(User.id). \ + filter(User.blocked). \ + all() + blocked_user_ids = [user_id for (user_id, ) in blocked_user_ids] + + blocked_users = get_documents_for_ids( + blocked_user_ids, None, user_profile_documents_config). \ + get('documents') + + return { + 'blocked': blocked_users + } diff --git a/c2corg_api/views/user_follow.py b/c2corg_api/views/user_follow.py index 8e9fa688d..809f817e9 100644 --- a/c2corg_api/views/user_follow.py +++ b/c2corg_api/views/user_follow.py @@ -2,12 +2,11 @@ from c2corg_api import DBSession from c2corg_api.models.feed import FollowedUser -from c2corg_api.models.user import User from c2corg_api.views import cors_policy, restricted_json_view from c2corg_api.views.document_listings import get_documents_for_ids from c2corg_api.views.document_schemas import user_profile_documents_config from c2corg_api.views.validation import validate_id, \ - validate_preferred_lang_param + validate_preferred_lang_param, validate_body_user_id from colander import MappingSchema, SchemaNode, Integer, required from cornice.resource import resource from cornice.validators import colander_body_validator @@ -19,20 +18,6 @@ class FollowSchema(MappingSchema): user_id = SchemaNode(Integer(), missing=required) -def validate_user_id(request, **kwargs): - """ Check that the user exists. - """ - user_id = request.validated['user_id'] - user_exists_query = DBSession.query(User). \ - filter(User.id == user_id). \ - exists() - user_exists = DBSession.query(user_exists_query).scalar() - - if not user_exists: - request.errors.add( - 'body', 'user_id', 'user {0} does not exist'.format(user_id)) - - def get_follower_relation(followed_user_id, follower_user_id): return DBSession. \ query(FollowedUser). \ @@ -49,7 +34,7 @@ def __init__(self, request): @restricted_json_view( schema=FollowSchema(), - validators=[colander_body_validator, validate_user_id]) + validators=[colander_body_validator, validate_body_user_id]) def post(self): """ Follow the given user. Creates a follower relation, so that the authenticated user is @@ -84,7 +69,7 @@ def __init__(self, request): @restricted_json_view( schema=FollowSchema(), - validators=[colander_body_validator, validate_user_id]) + validators=[colander_body_validator, validate_body_user_id]) def post(self): """ Unfollow the given user. diff --git a/c2corg_api/views/validation.py b/c2corg_api/views/validation.py index d6ea142ad..da906e97b 100644 --- a/c2corg_api/views/validation.py +++ b/c2corg_api/views/validation.py @@ -10,6 +10,7 @@ from c2corg_api.models.document_history import has_been_created_by from c2corg_api.models.image import IMAGE_TYPE from c2corg_api.models.outing import OUTING_TYPE +from c2corg_api.models.user import User from c2corg_api.models.xreport import XREPORT_TYPE from c2corg_api.models.route import ROUTE_TYPE from c2corg_api.models.user_profile import USERPROFILE_TYPE @@ -181,6 +182,20 @@ def parse_datetime(time_raw): return None +def validate_body_user_id(request, **kwargs): + """ Check that the user exists. + """ + user_id = request.validated['user_id'] + user_exists_query = DBSession.query(User). \ + filter(User.id == user_id). \ + exists() + user_exists = DBSession.query(user_exists_query).scalar() + + if not user_exists: + request.errors.add( + 'body', 'user_id', 'user {0} does not exist'.format(user_id)) + + def validate_token(request, **kwargs): if request.GET.get('token'): token = request.GET.get('token') diff --git a/requirements.txt b/requirements.txt index 8ca0d852f..8491800ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,6 +51,6 @@ git+https://github.com/tsauerwein/cornice.git@nested-none-2.1.0-c2corg git+https://github.com/c2corg/v6_common.git@2d06ab7 # Discourse API client -git+https://github.com/c2corg/pydiscourse.git@65e398343bbbc74fdb71cca4637c9f808e5adfb2 +git+https://github.com/c2corg/pydiscourse.git@3287330 -e .