Merge pull request #207 from grant-project/user-banning

User banning
This commit is contained in:
Daniel Ternyak 2019-02-15 13:12:30 -06:00 committed by GitHub
commit 30557e3b9c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 679 additions and 81 deletions

View File

@ -0,0 +1,27 @@
.FeedbackModal {
// watch these antd overrides when upgrading
// no icon, no margin
.ant-modal-confirm-content {
margin-left: 0; // override antd
margin-top: 1rem;
}
// hiding these so we can use our own
.ant-modal-confirm-btns {
display: none;
}
&-label {
font-weight: bold;
margin-bottom: 0.3rem;
}
// our own controls
&-controls {
margin-top: 1rem;
text-align: right;
& > button + button {
margin-left: 0.5rem;
}
}
}

View File

@ -0,0 +1,93 @@
import React, { ReactNode } from 'react';
import { Modal, Input, Button } from 'antd';
import { ModalFuncProps } from 'antd/lib/modal';
import TextArea from 'antd/lib/input/TextArea';
import './index.less';
interface OpenProps extends ModalFuncProps {
label: ReactNode;
onOk: (feedback: string) => void;
}
const open = (p: OpenProps) => {
// NOTE: display=none antd buttons and using our own to control things more
const ref = { text: '' };
const { label, content, okText, cancelText, ...rest } = p;
const modal = Modal.confirm({
maskClosable: true,
icon: <></>,
className: 'FeedbackModal',
content: (
<Feedback
label={label}
content={content}
okText={okText}
cancelText={cancelText}
onCancel={() => {
modal.destroy();
}}
onOk={() => {
modal.destroy();
p.onOk(ref.text);
}}
onChange={(t: string) => (ref.text = t)}
/>
),
...rest,
});
};
// Feedback content
interface OwnProps {
onChange: (t: string) => void;
label: ReactNode;
onOk: ModalFuncProps['onOk'];
onCancel: ModalFuncProps['onCancel'];
okText?: ReactNode;
cancelText?: ReactNode;
content?: ReactNode;
}
type Props = OwnProps;
const STATE = {
text: '',
};
type State = typeof STATE;
class Feedback extends React.Component<Props, State> {
state = STATE;
input: null | TextArea = null;
componentDidMount() {
if (this.input) this.input.focus();
}
render() {
const { text } = this.state;
const { label, onOk, onCancel, content, okText, cancelText } = this.props;
return (
<div>
{content && <p>{content}</p>}
<div className="FeedbackModal-label">{label}</div>
<Input.TextArea
ref={ta => (this.input = ta)}
rows={4}
required={true}
value={text}
onChange={e => {
this.setState({ text: e.target.value });
this.props.onChange(e.target.value);
}}
/>
<div className="FeedbackModal-controls">
<Button onClick={onCancel}>{cancelText || 'Cancel'}</Button>
<Button onClick={onOk} disabled={text.length === 0} type="primary">
{okText || 'Ok'}
</Button>
</div>
</div>
);
}
}
export default { open };

View File

@ -8,6 +8,13 @@
}
}
&-controls {
&-control + &-control {
margin-left: 0 !important;
margin-top: 0.8rem;
}
}
&-comment {
color: black;
margin-left: 1rem;

View File

@ -1,7 +1,19 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Row, Col, Card, Button, Collapse, Popconfirm, Avatar, List, message } from 'antd';
import {
Row,
Col,
Card,
Button,
Collapse,
Popconfirm,
Avatar,
List,
message,
Switch,
Modal,
} from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
import { Proposal, Comment, Contribution } from 'src/types';
@ -10,6 +22,8 @@ import { Link } from 'react-router-dom';
import Back from 'components/Back';
import './index.less';
import Markdown from 'components/Markdown';
import FeedbackModal from 'components/FeedbackModal';
import Info from '../Info';
type Props = RouteComponentProps<any>;
@ -24,7 +38,7 @@ class UserDetailNaked extends React.Component<Props, State> {
componentDidMount() {
this.loadDetail();
}
render() {
const id = this.getIdFromQuery();
const { userDetail: u, userDetailFetching } = store;
@ -36,21 +50,77 @@ class UserDetailNaked extends React.Component<Props, State> {
const renderDelete = () => (
<Popconfirm
onConfirm={this.handleDelete}
title={<>
Are you sure? Due to GDPR compliance,
<br/>
this <strong>cannot</strong> be undone.
</>}
title={
<>
Are you sure? Due to GDPR compliance,
<br />
this <strong>cannot</strong> be undone.
</>
}
okText="Delete"
cancelText="Cancel"
okType="danger"
>
<Button icon="delete" type="danger" ghost block>
<Button
icon="delete"
type="danger"
className="UserDetail-controls-control"
ghost
block
>
Delete
</Button>
</Popconfirm>
);
const renderSilenceControl = () => (
<div className="UserDetail-controls-control">
<Popconfirm
overlayClassName="UserDetail-popover-overlay"
onConfirm={this.handleToggleSilence}
title={<>{u.silenced ? 'Allow' : 'Disallow'} commenting?</>}
okText="ok"
cancelText="cancel"
>
<Switch checked={u.silenced} loading={store.userSaving} />{' '}
</Popconfirm>
<span>
Silence{' '}
<Info
placement="right"
content={
<span>
<b>Silence User</b>
<br /> User will not be able to comment.
</span>
}
/>
</span>
</div>
);
const renderBanControl = () => (
<div className="UserDetail-controls-control">
<Switch
checked={u.banned}
onChange={this.handleToggleBan}
loading={store.userSaving}
/>{' '}
<span>
Ban{' '}
<Info
placement="right"
content={
<span>
<b>Ban User</b>
<br /> User will not be able to sign-in or perform authenticated actions.
</span>
}
/>
</span>
</div>
);
const renderDeetItem = (name: string, val: any) => (
<div className="UserDetail-deet">
<span>{name}</span>
@ -115,7 +185,10 @@ class UserDetailNaked extends React.Component<Props, State> {
</Link>,
]}
>
<List.Item.Meta title={p.title} description={p.brief} />
<List.Item.Meta
title={p.title || '(no title)'}
description={p.brief || '(no brief)'}
/>
</List.Item>
)}
/>
@ -189,7 +262,11 @@ class UserDetailNaked extends React.Component<Props, State> {
{/* SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card size="small">{renderDelete()}</Card>
<Card size="small" className="UserDetail-controls">
{renderDelete()}
{renderSilenceControl()}
{renderBanControl()}
</Card>
</Col>
</Row>
</div>
@ -212,6 +289,68 @@ class UserDetailNaked extends React.Component<Props, State> {
this.props.history.replace('/users');
}
};
private handleToggleSilence = async () => {
if (store.userDetail) {
const ud = store.userDetail;
const newSilenced = !ud.silenced;
await store.editUser(ud.userid, { silenced: newSilenced });
if (store.userSaved) {
message.success(
<>
<b>{ud.displayName}</b> {newSilenced ? 'is silenced' : 'can comment again'}
</>,
2,
);
}
}
};
private handleToggleBan = () => {
if (store.userDetail) {
const ud = store.userDetail;
const newBanned = !ud.banned;
const informSuccess = () => {
if (store.userSaved) {
message.success(
<>
<b>{ud.displayName}</b> has been{' '}
{newBanned ? 'banned' : 'freed to roam the land'}
</>,
2,
);
}
};
if (newBanned) {
FeedbackModal.open({
title: 'Ban user?',
content: 'They will not be able to login.',
label: 'Please provide a reason:',
okText: 'Ban',
onOk: async reason => {
await store.editUser(ud.userid, { banned: newBanned, bannedReason: reason });
informSuccess();
},
});
} else {
Modal.confirm({
title: 'Unban user?',
okText: 'Unban',
content: (
<>
<p>This user was banned for the following reason: </p>
<q>{ud.bannedReason}</q>
</>
),
onOk: async () => {
await store.editUser(ud.userid, { banned: newBanned });
informSuccess();
},
});
}
}
};
}
const UserDetail = withRouter(view(UserDetailNaked));

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { view } from 'react-easy-state';
import { List, Avatar } from 'antd';
import { Link } from 'react-router-dom';
@ -8,21 +8,23 @@ import './UserItem.less';
class UserItemNaked extends React.Component<User> {
render() {
const p = this.props;
const actions = [<Link to={`/users/${p.userid}`} key="view">view</Link>];
const actions = [] as ReactNode[];
return (
<List.Item key={p.userid} className="UserItem" actions={actions}>
<List.Item.Meta
avatar={
<Avatar
shape="square"
icon="user"
src={(p.avatar && p.avatar.imageUrl) || ''}
/>
}
title={p.displayName}
description={p.emailAddress}
/>
<Link to={`/users/${p.userid}`} key="view">
<List.Item.Meta
avatar={
<Avatar
shape="square"
icon="user"
src={(p.avatar && p.avatar.imageUrl) || ''}
/>
}
title={p.displayName}
description={p.emailAddress}
/>
</Link>
</List.Item>
);
}

View File

@ -1,10 +1,11 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Button, List } from 'antd';
import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store';
import Pageable from 'components/Pageable';
import { User } from 'src/types';
import UserItem from './UserItem';
import { userFilters } from 'util/filters';
import './index.less';
type Props = RouteComponentProps<any>;
@ -15,22 +16,20 @@ class UsersNaked extends React.Component<Props> {
}
render() {
const { users, usersFetched, usersFetching } = store;
const loading = !usersFetched || usersFetching;
const { page } = store.users;
// NOTE: sync with /backend ... pagination.py UserPagination.SORT_MAP
const sorts = ['EMAIL:DESC', 'EMAIL:ASC', 'NAME:DESC', 'NAME:ASC'];
return (
<div className="Users">
<div className="Users-controls">
<Button title="refresh" icon="reload" onClick={() => store.fetchUsers()} />
</div>
<List
className="Users-list"
bordered
dataSource={users}
loading={loading}
renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
/>
</div>
<Pageable
page={page}
filters={userFilters}
sorts={sorts}
searchPlaceholder="Search user email or display name"
renderItem={(u: User) => <UserItem key={u.userid} {...u} />}
handleSearch={store.fetchUsers}
handleChangeQuery={store.setUserPageQuery}
handleResetQuery={store.resetUserPageQuery}
/>
);
}
}

View File

@ -42,8 +42,8 @@ async function fetchStats() {
return data;
}
async function fetchUsers() {
const { data } = await api.get('/admin/users');
async function fetchUsers(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/users', { params });
return data;
}
@ -52,6 +52,11 @@ async function fetchUserDetail(id: number) {
return data;
}
async function editUser(id: number, args: Partial<User>) {
const { data } = await api.put(`/admin/users/${id}`, args);
return data;
}
async function deleteUser(id: number) {
const { data } = await api.delete('/admin/users/' + id);
return data;
@ -165,9 +170,12 @@ const app = store({
proposalMilestonePayoutsCount: 0,
},
usersFetching: false,
usersFetched: false,
users: [] as User[],
users: {
page: createDefaultPageData<User>('EMAIL:DESC'),
},
userSaving: false,
userSaved: false,
userDetailFetching: false,
userDetail: null as null | User,
userDeleting: false,
@ -225,12 +233,15 @@ const app = store({
},
updateUserInStore(u: User) {
const index = app.users.findIndex(x => x.userid === u.userid);
const index = app.users.page.items.findIndex(x => x.userid === u.userid);
if (index > -1) {
app.users[index] = u;
app.users.page.items[index] = u;
}
if (app.userDetail && app.userDetail.userid === u.userid) {
app.userDetail = u;
app.userDetail = {
...app.userDetail,
...u,
};
}
},
@ -271,14 +282,40 @@ const app = store({
// Users
async fetchUsers() {
app.usersFetching = true;
app.users.page.fetching = true;
try {
app.users = await fetchUsers();
app.usersFetched = true;
const page = await fetchUsers(app.getUserPageQuery());
app.users.page = {
...app.users.page,
...page,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
app.usersFetching = false;
app.users.page.fetching = false;
},
getUserPageQuery() {
return pick(app.users.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
},
setUserPageQuery(query: Partial<PageQuery>) {
// sometimes we need to reset page to 1
if (query.filters || query.search) {
query.page = 1;
}
app.users.page = {
...app.users.page,
...query,
};
},
resetUserPageQuery() {
app.users.page.page = 1;
app.users.page.search = '';
app.users.page.sort = 'CREATED:DESC';
app.users.page.filters = [];
},
async fetchUserDetail(id: number) {
@ -291,12 +328,25 @@ const app = store({
app.userDetailFetching = false;
},
async editUser(id: number, args: Partial<User>) {
app.userSaving = true;
app.userSaved = false;
try {
const user = await editUser(id, args);
app.updateUserInStore(user);
app.userSaved = true;
} catch (e) {
handleApiError(e);
}
app.userSaving = false;
},
async deleteUser(id: number) {
app.userDeleting = false;
app.userDeleted = false;
try {
await deleteUser(id);
app.users = app.users.filter(u => u.userid !== id);
app.users.page.items = app.users.page.items.filter(u => u.userid !== id);
app.userDeleted = true;
app.userDetail = null;
} catch (e) {

View File

@ -149,6 +149,9 @@ export interface User {
proposals: Proposal[];
comments: Comment[];
contributions: Contribution[];
silenced: boolean;
banned: boolean;
bannedReason: string;
}
export interface EmailExample {

View File

@ -84,3 +84,30 @@ export const contributionFilters: Filters = {
list: CONTRIBUTION_FILTERS,
getById: getFilterById(CONTRIBUTION_FILTERS),
};
// User
const USER_FILTERS = [
{
id: `BANNED`,
display: `Banned`,
color: 'rgb(235, 65, 24)',
group: 'Misc',
},
{
id: `SILENCED`,
display: `Silenced`,
color: 'rgb(255, 170, 0)',
group: 'Misc',
},
{
id: `ARBITER`,
display: `Arbiter`,
color: 'rgb(16, 142, 233)',
group: 'Misc',
},
];
export const userFilters: Filters = {
list: USER_FILTERS,
getById: getFilterById(USER_FILTERS),
};

View File

@ -107,12 +107,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'])
@ -133,6 +145,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

@ -96,8 +96,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": "Your account has been silenced, commenting is disabled."}, 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("You are 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 grant.milestone.models import Milestone
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
@ -172,8 +173,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: d39bb526eef4
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 = 'd39bb526eef4'
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"