BE: user banning + silencing + pagination

This commit is contained in:
Aaron 2019-02-14 22:11:47 -06:00
parent 55994025c6
commit 1d811bb7d3
No known key found for this signature in database
GPG Key ID: 3B5B7597106F0A0E
14 changed files with 280 additions and 29 deletions

View File

@ -91,12 +91,24 @@ def delete_user(user_id):
@blueprint.route("/users", methods=["GET"])
@endpoint.api()
@endpoint.api(
parameter('page', type=int, required=False),
parameter('filters', type=list, required=False),
parameter('search', type=str, required=False),
parameter('sort', type=str, required=False)
)
@admin_auth_required
def get_users():
users = User.query.all()
result = admin_users_schema.dump(users)
return result
def get_users(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.user(
schema=admin_users_schema,
query=User.query,
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/users/<id>', methods=['GET'])
@ -117,6 +129,33 @@ def get_user(id):
return {"message": f"Could not find user with id {id}"}, 404
@blueprint.route('/users/<user_id>', methods=['PUT'])
@endpoint.api(
parameter('silenced', type=bool, required=False),
parameter('banned', type=bool, required=False),
parameter('bannedReason', type=str, required=False),
)
@admin_auth_required
def edit_user(user_id, silenced, banned, banned_reason):
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": f"Could not find user with id {id}"}, 404
if silenced is not None:
user.silenced = silenced
db.session.add(user)
if banned is not None:
if banned and not banned_reason: # if banned true, provide reason
return {"message": "Please include reason for banning"}, 417
user.banned = banned
user.banned_reason = banned_reason
db.session.add(user)
db.session.commit()
return admin_user_schema.dump(user)
# ARBITERS

View File

@ -9,6 +9,7 @@ from grant import commands, proposal, user, comment, milestone, admin, email, bl
from grant.extensions import bcrypt, migrate, db, ma, security
from grant.settings import SENTRY_RELEASE, ENV
from sentry_sdk.integrations.flask import FlaskIntegration
from grant.utils.auth import AuthException, handle_auth_error
def create_app(config_objects=["grant.settings"]):
@ -20,12 +21,18 @@ def create_app(config_objects=["grant.settings"]):
register_blueprints(app)
register_shellcontext(app)
register_commands(app)
if not app.config.get("TESTING"):
sentry_sdk.init(
environment=ENV,
release=SENTRY_RELEASE,
integrations=[FlaskIntegration()]
)
# handle all AuthExceptions thusly
# NOTE: testing mode does not honor this handler, and instead returns the generic 500 response
app.register_error_handler(AuthException, handle_auth_error)
return app

View File

@ -5,10 +5,10 @@ from .models import Comment, comments_schema
blueprint = Blueprint("comment", __name__, url_prefix="/api/v1/comment")
@blueprint.route("/", methods=["GET"])
@endpoint.api()
def get_comments():
all_comments = Comment.query.all()
result = comments_schema.dump(all_comments)
return result
# Unused
# @blueprint.route("/", methods=["GET"])
# @endpoint.api()
# def get_comments():
# all_comments = Comment.query.all()
# result = comments_schema.dump(all_comments)
# return result

View File

@ -5,10 +5,10 @@ from .models import Milestone, milestones_schema
blueprint = Blueprint('milestone', __name__, url_prefix='/api/v1/milestones')
@blueprint.route("/", methods=["GET"])
@endpoint.api()
def get_milestones():
milestones = Milestone.query.all()
result = milestones_schema.dump(milestones)
return result
# Unused
# @blueprint.route("/", methods=["GET"])
# @endpoint.api()
# def get_milestones():
# milestones = Milestone.query.all()
# result = milestones_schema.dump(milestones)
# return result

View File

@ -94,8 +94,11 @@ def post_proposal_comments(proposal_id, comment, parent_comment_id):
# Make sure user has verified their email
if not g.current_user.email_verification.has_verified:
message = "Please confirm your email before commenting."
return {"message": message}, 401
return {"message": "Please confirm your email before commenting"}, 401
# Make sure user is not silenced
if g.current_user.silenced:
return {"message": "User is silenced, cannot comment"}, 403
# Make the comment
comment = Comment(

View File

@ -107,6 +107,12 @@ class User(db.Model, UserMixin):
title = db.Column(db.String(255), unique=False, nullable=True)
active = db.Column(db.Boolean, default=True)
# moderation
silenced = db.Column(db.Boolean, default=False)
banned = db.Column(db.Boolean, default=False)
banned_reason = db.Column(db.String(), nullable=True)
# relations
social_medias = db.relationship(SocialMedia, backref="user", lazy=True, cascade="all, delete-orphan")
comments = db.relationship(Comment, backref="user", lazy=True)
avatar = db.relationship(Avatar, uselist=False, back_populates="user", cascade="all, delete-orphan")
@ -227,6 +233,17 @@ class User(db.Model, UserMixin):
'recover_url': make_url(f'/email/recover?code={er.code}'),
})
def set_banned(self, is_ban: bool, reason: str=None):
self.banned = is_ban
self.banned_reason = reason
db.session.add(self)
db.session.flush()
def set_silenced(self, is_silence: bool):
self.silenced = is_silence
db.session.add(self)
db.session.flush()
class SelfUserSchema(ma.Schema):
class Meta:
@ -241,6 +258,9 @@ class SelfUserSchema(ma.Schema):
"userid",
"email_verified",
"arbiter_proposals",
"silenced",
"banned",
"banned_reason",
)
social_medias = ma.Nested("SocialMediaSchema", many=True)

View File

@ -13,12 +13,12 @@ from grant.proposal.models import (
user_proposals_schema,
user_proposal_arbiters_schema
)
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user, throw_on_banned
from grant.utils.exceptions import ValidationException
from grant.utils.social import verify_social, get_social_login_url, VerifySocialException
from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException
from grant.utils.enums import ProposalStatus, ContributionStatus
from flask import current_app
from .models import (
User,
SocialMedia,
@ -146,6 +146,7 @@ def auth_user(email, password):
return {"message": "No user exists with that email"}, 400
if not existing_user.check_password(password):
return {"message": "Invalid password"}, 403
throw_on_banned(existing_user)
existing_user.login()
return self_user_schema.dump(existing_user)
@ -238,6 +239,7 @@ def recover_user(email):
existing_user = User.get_by_email(email)
if not existing_user:
return {"message": "No user exists with that email"}, 400
throw_on_banned(existing_user)
existing_user.send_recovery_email()
return None, 200
@ -251,6 +253,7 @@ def recover_email(code, password):
if er:
if er.is_expired():
return {"message": "Reset code expired"}, 401
throw_on_banned(er.user)
er.user.set_password(password)
db.session.delete(er)
db.session.commit()

View File

@ -8,15 +8,30 @@ from grant.settings import BLOCKCHAIN_API_SECRET
from grant.user.models import User
class AuthException(Exception):
pass
# use with: @blueprint.errorhandler(AuthException)
def handle_auth_error(e):
return jsonify(message=str(e)), 403
def get_authed_user():
return current_user if current_user.is_authenticated else None
def throw_on_banned(user):
if user.banned:
raise AuthException("User is banned")
def requires_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated:
return jsonify(message="Authentication is required to access this resource"), 401
throw_on_banned(current_user)
g.current_user = current_user
with sentry_sdk.configure_scope() as scope:
scope.user = {

View File

@ -2,6 +2,7 @@ import abc
from sqlalchemy import or_, and_
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.user.models import User, users_schema
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus
@ -166,8 +167,66 @@ class ContributionPagination(Pagination):
}
class UserPagination(Pagination):
def __init__(self):
self.FILTERS = ['BANNED', 'SILENCED', 'ARBITER']
self.PAGE_SIZE = 9
self.SORT_MAP = {
'EMAIL:DESC': User.email_address.desc(),
'EMAIL:ASC': User.email_address,
'NAME:DESC': User.display_name.desc(),
'NAME:ASC': User.display_name,
}
def paginate(
self,
schema: ma.Schema=users_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='EMAIL:DESC',
):
query = query or Proposal.query
sort = sort or 'EMAIL:DESC'
# FILTER
if filters:
self.validate_filters(filters)
if 'BANNED' in filters:
query = query.filter(User.banned == True)
if 'SILENCED' in filters:
query = query.filter(User.silenced == True)
if 'ARBITER' in filters:
query = query.join(User.arbiter_proposals) \
.filter(ProposalArbiter.status == ProposalArbiterStatus.ACCEPTED)
# SORT (see self.SORT_MAP)
if sort:
self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort])
# SEARCH
if search:
query = query.filter(
User.email_address.ilike(f'%{search}%') |
User.display_name.ilike(f'%{search}%')
)
res = query.paginate(page, self.PAGE_SIZE, False)
return {
'page': res.page,
'total': res.total,
'page_size': self.PAGE_SIZE,
'items': schema.dump(res.items),
'filters': filters,
'search': search,
'sort': sort
}
# expose pagination methods here
proposal = ProposalPagination().paginate
contribution = ContributionPagination().paginate
# comment = CommentPagination().paginate
# user = UserPagination().paginate
user = UserPagination().paginate

View File

@ -0,0 +1,32 @@
"""user banned & silenced fields
Revision ID: 27975c4a04a4
Revises: 86d300cb6d69
Create Date: 2019-02-14 10:30:47.596818
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '27975c4a04a4'
down_revision = '86d300cb6d69'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('banned', sa.Boolean(), nullable=True))
op.add_column('user', sa.Column('banned_reason', sa.String(), nullable=True))
op.add_column('user', sa.Column('silenced', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'silenced')
op.drop_column('user', 'banned_reason')
op.drop_column('user', 'banned')
# ### end Alembic commands ###

View File

@ -69,8 +69,9 @@ class TestAdminAPI(BaseProposalCreatorConfig):
self.login_admin()
resp = self.app.get("/api/v1/admin/users")
self.assert200(resp)
print(resp.json)
# 2 users created by BaseProposalCreatorConfig
self.assertEqual(len(resp.json), 2)
self.assertEqual(len(resp.json['items']), 2)
def test_get_proposals(self):
self.login_admin()

View File

@ -111,3 +111,23 @@ class TestProposalCommentAPI(BaseUserConfig):
)
self.assertStatus(comment_res, 403)
def test_create_new_proposal_comment_fails_with_silenced_user(self):
self.login_default_user()
self.user.set_silenced(True)
proposal = Proposal(
status="LIVE"
)
db.session.add(proposal)
db.session.commit()
proposal_id = proposal.id
comment_res = self.app.post(
"/api/v1/proposals/{}/comments".format(proposal_id),
data=json.dumps(test_comment),
content_type='application/json'
)
self.assertStatus(comment_res, 403)
self.assertIn('silenced', comment_res.json['message'])

View File

@ -119,6 +119,20 @@ class TestUserAPI(BaseUserConfig):
self.assert400(user_auth_resp)
self.assertTrue(user_auth_resp.json['message'] is not None)
def test_user_auth_banned(self):
self.user.set_banned(True, 'reason for banning')
user_auth_resp = self.app.post(
"/api/v1/users/auth",
data=json.dumps({
"email": self.user.email_address,
"password": self.user_password
}),
content_type="application/json"
)
# in test mode we get 500s instead of 403
self.assert500(user_auth_resp)
self.assertIn('banned', user_auth_resp.json['data'])
def test_create_user_duplicate_400(self):
# self.user is identical to test_user, should throw
response = self.app.post(
@ -224,6 +238,20 @@ class TestUserAPI(BaseUserConfig):
self.assertStatus(reset_resp, 401)
self.assertIsNotNone(reset_resp.json['message'])
@patch('grant.email.send.send_email')
def test_recover_user_banned(self, mock_send_email):
mock_send_email.return_value.ok = True
self.user.set_banned(True, 'Reason for banning')
# 1. request reset email
response = self.app.post(
"/api/v1/users/recover",
data=json.dumps({'email': self.user.email_address}),
content_type='application/json'
)
# 404 outside testing mode
self.assertStatus(response, 500)
self.assertIn('banned', response.json['data'])
def test_recover_user_no_user(self):
response = self.app.post(
"/api/v1/users/recover",
@ -244,6 +272,34 @@ class TestUserAPI(BaseUserConfig):
self.assertStatus(reset_resp, 400)
self.assertIsNotNone(reset_resp.json['message'])
@patch('grant.email.send.send_email')
def test_recover_user_code_banned(self, mock_send_email):
mock_send_email.return_value.ok = True
# 1. request reset email
response = self.app.post(
"/api/v1/users/recover",
data=json.dumps({'email': self.user.email_address}),
content_type='application/json'
)
self.assertStatus(response, 200)
er = self.user.email_recovery
code = er.code
self.user.set_banned(True, "Reason")
# 2. reset password
new_password = 'n3wp455w3rd'
reset_resp = self.app.post(
f"/api/v1/users/recover/{code}",
data=json.dumps({'password': new_password}),
content_type='application/json'
)
# 403 outside of testing mode
self.assertStatus(reset_resp, 500)
self.assertIn('banned', reset_resp.json['data'])
@patch('grant.user.views.verify_social')
def test_user_verify_social(self, mock_verify_social):
mock_verify_social.return_value = 'billy'

View File

@ -12857,10 +12857,6 @@ redux-logger@^3.0.6:
dependencies:
deep-diff "^0.3.5"
redux-persist@5.10.0:
version "5.10.0"
resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-5.10.0.tgz#5d8d802c5571e55924efc1c3a9b23575283be62b"
redux-promise-middleware@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/redux-promise-middleware/-/redux-promise-middleware-5.1.1.tgz#37689339a58a33d1fda675ed1ba2053a2d196b8d"