Merge pull request #227 from grant-project/comment-moderation

Comment moderation
This commit is contained in:
Daniel Ternyak 2019-02-20 15:52:05 -06:00 committed by GitHub
commit a4c7f25442
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 681 additions and 151 deletions

View File

@ -18,6 +18,7 @@ import RFPDetail from 'components/RFPDetail';
import Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail';
import Moderation from 'components/Moderation';
import 'styles/style.less';
@ -49,6 +50,7 @@ class Routes extends React.Component<Props> {
<Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} />
<Route path="/emails/:type?" component={Emails} />
<Route path="/moderation" component={Moderation} />
</Switch>
)}
</Template>

View File

@ -0,0 +1,8 @@
.ModerationItem {
& .ant-list-item-meta-title {
& small {
color: #868686;
font-weight: normal;
}
}
}

View File

@ -0,0 +1,81 @@
import React from 'react';
import { view } from 'react-easy-state';
import store from 'src/store';
import { Popconfirm, List, Avatar, Icon, message } from 'antd';
import { Link } from 'react-router-dom';
import { Comment } from 'src/types';
import { formatDateSeconds } from 'util/time';
import Markdown from '../Markdown';
import ShowMore from 'components/ShowMore';
import './ModerationItem.less';
class ModerationItem extends React.Component<Comment> {
render() {
const p = this.props;
const avatarUrl = (p.author!.avatar && p.author!.avatar!.imageUrl) || undefined;
const actions = [
<Popconfirm
key="toggleHide"
onConfirm={this.handleHide}
title={`${
p.hidden ? 'Show' : 'Hide'
} the content of this comment on public view?`}
okText={p.hidden ? 'Show' : 'Hide'}
okType="primary"
placement="left"
>
<a>{p.hidden ? 'show' : 'hide'}</a>
</Popconfirm>,
];
return (
<List.Item className="ModerationItem" actions={actions}>
<List.Item.Meta
avatar={<Avatar icon="user" src={avatarUrl} shape="square" />}
title={
<>
<Link to={`/users/${p.author!.userid}`}>{p.author!.displayName}</Link>{' '}
<small>commented on</small>{' '}
<Link to={`/proposals/${p.proposalId}`}>
{p.proposal && p.proposal.title}
</Link>{' '}
<small>at {formatDateSeconds(p.dateCreated)}</small>{' '}
{p.hidden && (
<>
<Icon type="eye-invisible" />{' '}
</>
)}
{p.reported && (
<>
<Icon type="flag" />{' '}
</>
)}
</>
}
description={
<ShowMore height={100}>
<Markdown source={p.content} />
</ShowMore>
}
/>
</List.Item>
);
}
private handleHide = async () => {
await store.updateComment(this.props.id, { hidden: !this.props.hidden });
if (store.commentSaved) {
message.success(
<>
<b>
{this.props.author!.displayName}
's
</b>{' '}
comment on <b>{this.props.proposal!.title}</b>{' '}
{this.props.hidden ? 'hidden' : 'now visible'}
</>,
);
}
};
}
export default view(ModerationItem);

View File

@ -0,0 +1,29 @@
import React from 'react';
import { view } from 'react-easy-state';
import store from 'src/store';
import ModerationItem from './ModerationItem';
import Pageable from 'components/Pageable';
import { Comment } from 'src/types';
import { commentFilters } from 'src/util/filters';
class Moderation extends React.Component<{}> {
render() {
const { page } = store.comments;
// NOTE: sync with /backend ... pagination.py ProposalCommentPagination.SORT_MAP
const sorts = ['CREATED:DESC', 'CREATED:ASC'];
return (
<Pageable
page={page}
filters={commentFilters}
sorts={sorts}
searchPlaceholder="Search comment content"
renderItem={(p: Comment) => <ModerationItem key={p.id} {...p} />}
handleSearch={store.fetchComments}
handleChangeQuery={store.setCommentPageParams}
handleResetQuery={store.resetCommentPageParams}
/>
);
}
}
export default view(Moderation);

View File

@ -10,7 +10,7 @@ import './index.less';
interface OwnProps<T> {
page: PageData<T>;
filters: Filters;
filters: null | Filters;
sorts: string[];
searchPlaceholder?: string;
controlsExtra?: React.ReactNode;
@ -39,7 +39,7 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
} = this.props;
const loading = !page.fetched || page.fetching;
const statusFilterMenu = (
const statusFilterMenu = filters && (
<Menu onClick={this.handleFilterClick}>
{filters.list.map(f => (
<Menu.Item key={f.id}>{f.display}</Menu.Item>
@ -63,11 +63,13 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
placeholder={searchPlaceholder}
onSearch={this.handleSearch}
/>
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
{filters && (
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
)}
<Dropdown overlay={sortMenu} trigger={['click']}>
<Button>
{'Sort ' + page.sort} <Icon type="down" />
@ -86,29 +88,30 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
</div>
)}
{!!page.filters.length && (
<div className="Pageable-filters">
Filters:{' '}
{page.filters.map(fId => {
const f = filters.getById(fId);
return (
<Tag
key={fId}
onClose={() => this.handleFilterClose(fId)}
color={f.color}
closable
>
{f.display}
{filters &&
!!page.filters.length && (
<div className="Pageable-filters">
Filters:{' '}
{page.filters.map(fId => {
const f = filters.getById(fId);
return (
<Tag
key={fId}
onClose={() => this.handleFilterClose(fId)}
color={f.color}
closable
>
{f.display}
</Tag>
);
})}
{page.filters.length > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
);
})}
{page.filters.length > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
)}
)}
</div>
)}
<List
className="Pageable-list"

View File

@ -3,7 +3,19 @@ import moment from 'moment';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom';
import { Form, Input, Select, Icon, Button, message, Spin, Checkbox, Row, Col, DatePicker } from 'antd';
import {
Form,
Input,
Select,
Icon,
Button,
message,
Spin,
Checkbox,
Row,
Col,
DatePicker,
} from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types';
@ -70,7 +82,7 @@ class RFPForm extends React.Component<Props, State> {
return <Exception type="404" desc="This RFP does not exist" />;
}
}
const dateCloses = isFieldsTouched(['dateCloses'])
? getFieldValue('dateCloses')
: defaults.dateCloses && moment(defaults.dateCloses * 1000);
@ -100,7 +112,9 @@ class RFPForm extends React.Component<Props, State> {
{rfpId && (
<Form.Item
label="Status"
help={forceClosed && 'Status is forced to "Closed" when close date is in the past'}
help={
forceClosed && 'Status is forced to "Closed" when close date is in the past'
}
>
{getFieldDecorator('status', {
initialValue: defaults.status,
@ -214,10 +228,10 @@ class RFPForm extends React.Component<Props, State> {
help="Date that proposals will stop being submittable by"
>
{getFieldDecorator('dateCloses', {
initialValue: defaults.dateCloses ? moment(defaults.dateCloses * 1000) : undefined,
})(
<DatePicker size="large" />
)}
initialValue: defaults.dateCloses
? moment(defaults.dateCloses * 1000)
: undefined,
})(<DatePicker size="large" />)}
</Form.Item>
</Col>
</Row>

View File

@ -0,0 +1,14 @@
.ShowMore {
overflow: hidden;
position: relative;
&-controls {
position: absolute;
bottom: -0.3rem;
left: 0;
right: 0;
text-align: center;
font-size: 0.8rem;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0), #ffffff, #ffffff);
}
}

View File

@ -0,0 +1,73 @@
import React from 'react';
import './index.less';
interface Props {
height: number;
units?: 'px' | 'rem';
}
const STATE = {
isOverflowing: false,
isShowing: false,
hasChecked: false,
};
type State = typeof STATE;
// very basic "show more" wrapper component, doesn't yet support
// - window resize height changes
// - using the parent or natural height as the desired height
export class ShowMore extends React.Component<Props, State> {
state = STATE;
wrapper: null | HTMLDivElement = null;
componentDidMount() {
this.updateOverflow();
}
componentDidUpdate(_: Props, s: State) {
const isShowingChange = s.isShowing !== this.state.isShowing;
const reset = !isShowingChange && (s.hasChecked && this.state.hasChecked);
const mustCheck = s.hasChecked && !this.state.hasChecked;
if (reset) {
this.setState(STATE);
}
if (mustCheck) {
this.updateOverflow();
}
}
render() {
const p = this.props;
const s = this.state;
const maxHeight = `${p.height}${p.units || 'px'}`;
const style = !s.hasChecked || (s.isOverflowing && !s.isShowing) ? { maxHeight } : {};
return (
<div style={style} className="ShowMore" ref={d => (this.wrapper = d)}>
{p.children}
{s.isOverflowing &&
!s.isShowing && (
<div className="ShowMore-controls">
<a onClick={() => this.setState({ isShowing: true })}>show more</a>
</div>
)}
{s.isOverflowing &&
s.isShowing && (
<div className="ShowMore-controls">
<a onClick={() => this.setState({ isShowing: false })}>hide</a>
</div>
)}
</div>
);
}
private updateOverflow = () => {
const w = this.wrapper;
if (!w) return;
// natural hight is ok
if (w.clientHeight < this.props.height) {
this.setState({ isOverflowing: false, hasChecked: true });
return;
}
// tall, let us check...
this.setState({ isOverflowing: w.scrollHeight > w.clientHeight, hasChecked: true });
};
}
export default ShowMore;

View File

@ -69,6 +69,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">Emails</span>
</Link>
</Menu.Item>
<Menu.Item key="moderation">
<Link to="/moderation">
<Icon type="message" />
<span className="nav-text">Moderation</span>
</Link>
</Menu.Item>
<Menu.Item key="logout" onClick={store.logout}>
<Icon type="logout" />
<span className="nav-text">Logout</span>

View File

@ -11,6 +11,7 @@ import {
EmailExample,
PageQuery,
PageData,
CommentArgs,
} from './types';
// API
@ -100,6 +101,16 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st
return data;
}
async function fetchComments(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/comments', { params });
return data;
}
async function updateComment(id: number, args: Partial<CommentArgs>) {
const { data } = await api.put(`/admin/comments/${id}`, args);
return data;
}
async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) {
const { data } = await api.put(
`/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`,
@ -200,6 +211,12 @@ const app = store({
proposalDetailApproving: false,
proposalDetailMarkingMilestonePaid: false,
comments: {
page: createDefaultPageData<Comment>('CREATED:DESC'),
},
commentSaving: false,
commentSaved: false,
rfps: [] as RFP[],
rfpsFetching: false,
rfpsFetched: false,
@ -285,40 +302,15 @@ const app = store({
// Users
async fetchUsers() {
app.users.page.fetching = true;
try {
const page = await fetchUsers(app.getUserPageQuery());
app.users.page = {
...app.users.page,
...page,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
app.users.page.fetching = false;
return await pageFetch(app.users, fetchUsers);
},
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,
};
setUserPageQuery(params: Partial<PageQuery>) {
setPageParams(app.users, params);
},
resetUserPageQuery() {
app.users.page.page = 1;
app.users.page.search = '';
app.users.page.sort = 'CREATED:DESC';
app.users.page.filters = [];
resetPageParams(app.users);
},
async fetchUserDetail(id: number) {
@ -404,40 +396,15 @@ const app = store({
// Proposals
async fetchProposals() {
app.proposals.page.fetching = true;
try {
const page = await fetchProposals(app.getProposalPageQuery());
app.proposals.page = {
...app.proposals.page,
...page,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
app.proposals.page.fetching = false;
return await pageFetch(app.proposals, fetchProposals);
},
setProposalPageQuery(query: Partial<PageQuery>) {
// sometimes we need to reset page to 1
if (query.filters || query.search) {
query.page = 1;
}
app.proposals.page = {
...app.proposals.page,
...query,
};
},
getProposalPageQuery() {
return pick(app.proposals.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
setProposalPageQuery(params: Partial<PageQuery>) {
setPageParams(app.proposals, params);
},
resetProposalPageQuery() {
app.proposals.page.page = 1;
app.proposals.page.search = '';
app.proposals.page.sort = 'CREATED:DESC';
app.proposals.page.filters = [];
resetPageParams(app.proposals);
},
async fetchProposalDetail(id: number) {
@ -505,6 +472,33 @@ const app = store({
app.proposalDetailMarkingMilestonePaid = false;
},
// Comments
async fetchComments() {
return await pageFetch(app.comments, fetchComments);
},
setCommentPageParams(params: Partial<PageQuery>) {
setPageParams(app.comments, params);
},
resetCommentPageParams() {
resetPageParams(app.comments);
},
async updateComment(id: number, args: Partial<CommentArgs>) {
app.commentSaving = true;
app.commentSaved = false;
try {
await updateComment(id, args);
app.commentSaved = true;
await app.fetchComments();
} catch (e) {
handleApiError(e);
}
app.commentSaving = false;
},
// Email
async getEmailExample(type: string) {
@ -573,45 +567,15 @@ const app = store({
// Contributions
async fetchContributions() {
app.contributions.page.fetching = true;
try {
const page = await getContributions(app.getContributionPageQuery());
app.contributions.page = {
...app.contributions.page,
...page,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
app.contributions.page.fetching = false;
return await pageFetch(app.contributions, getContributions);
},
setContributionPageQuery(query: Partial<PageQuery>) {
// sometimes we need to reset page to 1
if (query.filters || query.search) {
query.page = 1;
}
app.contributions.page = {
...app.contributions.page,
...query,
};
},
getContributionPageQuery() {
return pick(app.contributions.page, [
'page',
'search',
'filters',
'sort',
]) as PageQuery;
setContributionPageQuery(params: Partial<PageQuery>) {
setPageParams(app.contributions, params);
},
resetContributionPageQuery() {
app.contributions.page.page = 1;
app.contributions.page.search = '';
app.contributions.page.sort = 'CREATED:DESC';
app.contributions.page.filters = [];
resetPageParams(app.contributions);
},
async fetchContributionDetail(id: number) {
@ -674,6 +638,49 @@ function createDefaultPageData<T>(sort: string): PageData<T> {
};
}
type FNFetchPage = (params: PageQuery) => Promise<any>;
interface PageParent<T> {
page: PageData<T>;
}
async function pageFetch<T>(ref: PageParent<T>, fetch: FNFetchPage) {
ref.page.fetching = true;
try {
const params = getPageParams(ref.page);
const newPage = await fetch(params);
ref.page = {
...ref.page,
...newPage,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
ref.page.fetching = false;
}
function getPageParams<T>(page: PageData<T>) {
return pick(page, ['page', 'search', 'filters', 'sort']) as PageQuery;
}
function setPageParams<T>(ref: PageParent<T>, query: Partial<PageQuery>) {
// sometimes we need to reset page to 1
if (query.filters || query.search) {
query.page = 1;
}
ref.page = {
...ref.page,
...query,
};
}
function resetPageParams<T>(ref: PageParent<T>) {
ref.page.page = 1;
ref.page.search = '';
ref.page.sort = 'CREATED:DESC';
ref.page.filters = [];
}
// Attach to window for inspection
(window as any).appStore = app;

View File

@ -104,7 +104,6 @@ export interface Proposal {
currentMilestone?: Milestone;
team: User[];
comments: Comment[];
contractStatus: string;
target: string;
contributed: string;
funded: string;
@ -114,11 +113,19 @@ export interface Proposal {
arbiter: ProposalArbiter;
}
export interface Comment {
commentId: string;
id: number;
userId: User['userid'];
author?: User;
proposalId: Proposal['proposalId'];
proposal?: Proposal;
dateCreated: number;
content: string;
hidden: boolean;
reported: boolean;
}
export interface CommentArgs {
hidden: boolean;
reported: boolean;
}
// NOTE: sync with backend/utils/enums.py
export enum CONTRIBUTION_STATUS {

View File

@ -120,3 +120,24 @@ export const userFilters: Filters = {
list: USER_FILTERS,
getById: getFilterById(USER_FILTERS),
};
// Comment
const COMMENT_FILTERS = [
{
id: `REPORTED`,
display: `Reported`,
color: '#ffaa00',
group: 'Misc',
},
{
id: `HIDDEN`,
display: `Hidden`,
color: '#bebebe',
group: 'Misc',
},
];
export const commentFilters = {
list: COMMENT_FILTERS,
getById: getFilterById(COMMENT_FILTERS),
};

View File

@ -3,7 +3,7 @@ from flask import Blueprint, request
from flask_yoloapi import endpoint, parameter
from decimal import Decimal
from datetime import datetime
from grant.comment.models import Comment, user_comments_schema
from grant.comment.models import Comment, user_comments_schema, admin_comments_schema, admin_comment_schema
from grant.email.send import generate_email, send_email
from grant.extensions import db
from grant.proposal.models import (
@ -575,3 +575,47 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200
# Comments
@blueprint.route('/comments', methods=['GET'])
@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_comments(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.comment(
page=page,
filters=filters_workaround,
search=search,
sort=sort,
schema=admin_comments_schema
)
return page
@blueprint.route('/comments/<comment_id>', methods=['PUT'])
@endpoint.api(
parameter('hidden', type=bool, required=False),
parameter('reported', type=bool, required=False),
)
@admin_auth_required
def edit_comment(comment_id, hidden, reported):
comment = Comment.query.filter(Comment.id == comment_id).first()
if not comment:
return {"message": "No comment matching that id"}, 404
if hidden is not None:
comment.hide(hidden)
if reported is not None:
comment.report(reported)
db.session.commit()
return admin_comment_schema.dump(comment)

View File

@ -1,9 +1,12 @@
import datetime
from functools import reduce
from grant.extensions import ma, db
from grant.utils.misc import dt_to_unix
from grant.utils.ma_fields import UnixDate
from sqlalchemy.orm import raiseload
HIDDEN_CONTENT = '~~comment removed by admin~~'
class Comment(db.Model):
__tablename__ = "comment"
@ -11,6 +14,8 @@ class Comment(db.Model):
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
content = db.Column(db.Text, nullable=False)
hidden = db.Column(db.Boolean, nullable=False, default=False, server_default=db.text("FALSE"))
reported = db.Column(db.Boolean, nullable=True, default=False, server_default=db.text("FALSE"))
parent_comment_id = db.Column(db.Integer, db.ForeignKey("comment.id"), nullable=True)
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
@ -34,6 +39,24 @@ class Comment(db.Model):
.order_by(Comment.date_created.desc()) \
.all()
def report(self, reported: bool):
self.reported = reported
db.session.add(self)
def hide(self, hidden: bool):
self.hidden = hidden
db.session.add(self)
# are all of the replies hidden?
def all_hidden(replies):
return reduce(lambda ah, r: ah and r.hidden, replies, True)
# remove replies that are hidden and have all hidden children or no children
def filter_dead(replies):
return [x for x in replies if not (x.hidden and all_hidden(x.replies))]
class CommentSchema(ma.Schema):
class Meta:
@ -46,15 +69,23 @@ class CommentSchema(ma.Schema):
"content",
"parent_comment_id",
"date_created",
"replies"
"replies",
"reported",
"hidden",
)
date_created = ma.Method("get_date_created")
content = ma.Method("get_content")
date_created = UnixDate(attribute='date_created')
author = ma.Nested("UserSchema")
replies = ma.Nested("CommentSchema", many=True)
# custome handling of replies, was: replies = ma.Nested("CommentSchema", many=True)
replies = ma.Method("get_replies")
def get_date_created(self, obj):
return dt_to_unix(obj.date_created)
def get_content(self, obj):
return HIDDEN_CONTENT if obj.hidden else obj.content
# filter out "dead" comments
def get_replies(self, obj):
return comments_schema.dump(filter_dead(obj.replies))
comment_schema = CommentSchema()
@ -69,6 +100,8 @@ class UserCommentSchema(ma.Schema):
"proposal",
"content",
"date_created",
"reported",
"hidden",
)
proposal = ma.Nested(
@ -78,14 +111,56 @@ class UserCommentSchema(ma.Schema):
"milestones",
"content",
"invites",
"updates"
"updates",
]
)
date_created = ma.Method("get_date_created")
def get_date_created(self, obj):
return dt_to_unix(obj.date_created) * 1000
content = ma.Method("get_content")
date_created = UnixDate(attribute='date_created')
def get_content(self, obj):
return HIDDEN_CONTENT if obj.hidden else obj.content
user_comment_schema = UserCommentSchema()
user_comments_schema = UserCommentSchema(many=True)
class AdminCommentSchema(ma.Schema):
class Meta:
model = Comment
fields = (
"id",
"user_id",
"author",
"proposal",
"proposal_id",
"content",
"date_created",
"reported",
"hidden",
)
proposal = ma.Nested(
"ProposalSchema",
only=[
"proposal_id",
"title",
"brief"
]
)
author = ma.Nested(
"SelfUserSchema",
only=[
"userid",
"email_address",
"display_name",
"title",
"avatar",
]
)
date_created = UnixDate(attribute='date_created')
admin_comment_schema = AdminCommentSchema()
admin_comments_schema = AdminCommentSchema(many=True)

View File

@ -70,7 +70,7 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.comment(
schema=comments_schema,
query=Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None),
query=Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None, hidden=False),
page=page,
filters=filters_workaround,
search=search,
@ -79,6 +79,24 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
return page
@blueprint.route("/<proposal_id>/comments/<comment_id>/report", methods=["PUT"])
@requires_email_verified_auth
@endpoint.api()
def report_proposal_comment(proposal_id, comment_id):
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
comment = Comment.query.filter_by(id=comment_id).first()
if not comment:
return {"message": "Comment doesnt exist"}, 404
comment.report(True)
db.session.commit()
return None, 200
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
@requires_email_verified_auth
@endpoint.api(

View File

@ -3,6 +3,7 @@ from sqlalchemy import or_, and_
from grant.comment.models import Comment, comments_schema
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from grant.comment.models import Comment, comments_schema
from grant.user.models import User, users_schema
from grant.milestone.models import Milestone
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
@ -232,7 +233,7 @@ class UserPagination(Pagination):
class CommentPagination(Pagination):
def __init__(self):
self.FILTERS = []
self.FILTERS = ['REPORTED', 'HIDDEN']
self.PAGE_SIZE = 10
self.SORT_MAP = {
'CREATED:DESC': Comment.date_created.desc(),
@ -253,18 +254,22 @@ class CommentPagination(Pagination):
# FILTER
if filters:
pass
self.validate_filters(filters)
if 'REPORTED' in filters:
query = query.filter(Comment.reported == True)
if 'HIDDEN' in filters:
query = query.filter(Comment.hidden == True)
# SORT (see self.SORT_MAP)
if sort:
self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort])
# SEARCH can match txids or amounts
# SEARCH
if search:
query = query.filter(or_(
Comment.content.ilike(f'%{search}%'),
))
query = query.filter(
Comment.content.ilike(f'%{search}%')
)
res = query.paginate(page, self.PAGE_SIZE, False)
return {

View File

@ -0,0 +1,30 @@
"""basic comment table moderation fields - hidden, reported
Revision ID: 02acd43b4357
Revises: 27975c4a04a4
Create Date: 2019-02-17 17:17:17.677275
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '02acd43b4357'
down_revision = '27975c4a04a4'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('comment', sa.Column('hidden', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False))
op.add_column('comment', sa.Column('reported', sa.Boolean(), server_default=sa.text('FALSE'), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('comment', 'reported')
op.drop_column('comment', 'hidden')
# ### end Alembic commands ###

View File

@ -45,6 +45,10 @@ export function getProposalComments(proposalId: number | string, params: PagePar
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
}
export function reportProposalComment(proposalId: number, commentId: number) {
return axios.put(`/api/v1/proposals/${proposalId}/comments/${commentId}/report`);
}
export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/updates`);
}

View File

@ -1,12 +1,12 @@
import React from 'react';
import { connect } from 'react-redux';
import moment from 'moment';
import { Button } from 'antd';
import { Button, message } from 'antd';
import { Link } from 'react-router-dom';
import Markdown from 'components/Markdown';
import UserAvatar from 'components/UserAvatar';
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
import { postProposalComment } from 'modules/proposals/actions';
import { postProposalComment, reportProposalComment } from 'modules/proposals/actions';
import { getIsSignedIn } from 'modules/auth/selectors';
import { Comment as IComment } from 'types';
import { AppState } from 'store/reducers';
@ -24,6 +24,7 @@ interface StateProps {
interface DispatchProps {
postProposalComment: typeof postProposalComment;
reportProposalComment: typeof reportProposalComment;
}
type Props = OwnProps & StateProps & DispatchProps;
@ -76,7 +77,12 @@ class Comment extends React.Component<Props> {
<a className="Comment-controls-button" onClick={this.toggleReply}>
{isReplying ? 'Cancel' : 'Reply'}
</a>
{/*<a className="Comment-controls-button">Report</a>*/}
{!comment.hidden &&
!comment.reported && (
<a className="Comment-controls-button" onClick={this.report}>
Report
</a>
)}
</div>
)}
@ -120,6 +126,16 @@ class Comment extends React.Component<Props> {
const { reply } = this.state;
this.props.postProposalComment(comment.proposalId, reply, comment.id);
};
private report = async () => {
const { proposalId, id } = this.props.comment;
const res = await this.props.reportProposalComment(proposalId, id);
if ((res as any).error) {
message.error('Problem reporting comment: ' + (res as any).payload);
} else {
message.success('Comment reported');
}
};
}
const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
@ -130,6 +146,7 @@ const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
}),
{
postProposalComment,
reportProposalComment,
},
)(Comment);

View File

@ -4,6 +4,7 @@ import {
getProposal,
getProposalComments,
getProposalUpdates,
reportProposalComment as apiReportProposalComment,
getProposalContributions,
postProposalComment as apiPostProposalComment,
requestProposalPayout,
@ -204,3 +205,28 @@ export function postProposalComment(
}
};
}
export function reportProposalComment(
proposalId: Proposal['proposalId'],
commentId: Comment['id'],
) {
return async (dispatch: Dispatch<any>) => {
dispatch({ type: types.REPORT_PROPOSAL_COMMENT_PENDING, payload: { commentId } });
try {
await apiReportProposalComment(proposalId, commentId);
return dispatch({
type: types.REPORT_PROPOSAL_COMMENT_FULFILLED,
payload: {
commentId,
},
});
} catch (err) {
return dispatch({
type: types.REPORT_PROPOSAL_COMMENT_REJECTED,
payload: err.message || err.toString(),
error: true,
});
}
};
}

View File

@ -172,6 +172,42 @@ function addPostedComment(state: ProposalState, payload: PostCommentPayload) {
};
}
function updateCommentInStore(
state: ProposalState,
commentId: Comment['id'],
update: Partial<Comment>,
) {
// clone so we can mutate with great abandon!
const pages = cloneDeep(state.detailComments.pages);
// recursive populate replies for nested comment
const f = (id: number, p: Comment) => {
if (p.id === id) {
Object.entries(update).forEach(([k, v]) => {
(p as any)[k] = v;
});
return;
} else {
p.replies.forEach(x => f(id, x));
}
};
// pages > page > comments
pages.forEach(p =>
p.forEach(c => {
f(commentId, c);
}),
);
return {
...state,
isPostCommentPending: false,
detailComments: {
...state.detailComments,
pages,
total: state.detailComments.total + 1,
},
};
}
export default (state = INITIAL_STATE, action: any) => {
const { payload } = action;
switch (action.type) {
@ -336,6 +372,9 @@ export default (state = INITIAL_STATE, action: any) => {
},
};
case types.REPORT_PROPOSAL_COMMENT_FULFILLED:
return updateCommentInStore(state, payload.commentId, { reported: true });
case types.PROPOSAL_UPDATES_PENDING:
return {
...state,

View File

@ -47,6 +47,11 @@ enum proposalTypes {
PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED',
PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED',
PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING',
REPORT_PROPOSAL_COMMENT = 'REPORT_PROPOSAL_COMMENT',
REPORT_PROPOSAL_COMMENT_PENDING = 'REPORT_PROPOSAL_COMMENT_PENDING',
REPORT_PROPOSAL_COMMENT_FULFILLED = 'REPORT_PROPOSAL_COMMENT_FULFILLED',
REPORT_PROPOSAL_COMMENT_REJECTED = 'REPORT_PROPOSAL_COMMENT_REJECTED',
}
export default proposalTypes;

View File

@ -7,6 +7,8 @@ export interface Comment {
dateCreated: number;
author: User;
replies: Comment[];
reported: boolean;
hidden: boolean;
}
export interface UserComment {