BE: user banning + silencing + pagination
This commit is contained in:
parent
55994025c6
commit
1d811bb7d3
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ###
|
|
@ -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()
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue