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 Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm'; import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail'; import ContributionDetail from 'components/ContributionDetail';
import Moderation from 'components/Moderation';
import 'styles/style.less'; import 'styles/style.less';
@ -49,6 +50,7 @@ class Routes extends React.Component<Props> {
<Route path="/contributions/:id" component={ContributionDetail} /> <Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} /> <Route path="/contributions" component={Contributions} />
<Route path="/emails/:type?" component={Emails} /> <Route path="/emails/:type?" component={Emails} />
<Route path="/moderation" component={Moderation} />
</Switch> </Switch>
)} )}
</Template> </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> { interface OwnProps<T> {
page: PageData<T>; page: PageData<T>;
filters: Filters; filters: null | Filters;
sorts: string[]; sorts: string[];
searchPlaceholder?: string; searchPlaceholder?: string;
controlsExtra?: React.ReactNode; controlsExtra?: React.ReactNode;
@ -39,7 +39,7 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
} = this.props; } = this.props;
const loading = !page.fetched || page.fetching; const loading = !page.fetched || page.fetching;
const statusFilterMenu = ( const statusFilterMenu = filters && (
<Menu onClick={this.handleFilterClick}> <Menu onClick={this.handleFilterClick}>
{filters.list.map(f => ( {filters.list.map(f => (
<Menu.Item key={f.id}>{f.display}</Menu.Item> <Menu.Item key={f.id}>{f.display}</Menu.Item>
@ -63,11 +63,13 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
onSearch={this.handleSearch} onSearch={this.handleSearch}
/> />
<Dropdown overlay={statusFilterMenu} trigger={['click']}> {filters && (
<Button> <Dropdown overlay={statusFilterMenu} trigger={['click']}>
Filter <Icon type="down" /> <Button>
</Button> Filter <Icon type="down" />
</Dropdown> </Button>
</Dropdown>
)}
<Dropdown overlay={sortMenu} trigger={['click']}> <Dropdown overlay={sortMenu} trigger={['click']}>
<Button> <Button>
{'Sort ' + page.sort} <Icon type="down" /> {'Sort ' + page.sort} <Icon type="down" />
@ -86,29 +88,30 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
</div> </div>
)} )}
{!!page.filters.length && ( {filters &&
<div className="Pageable-filters"> !!page.filters.length && (
Filters:{' '} <div className="Pageable-filters">
{page.filters.map(fId => { Filters:{' '}
const f = filters.getById(fId); {page.filters.map(fId => {
return ( const f = filters.getById(fId);
<Tag return (
key={fId} <Tag
onClose={() => this.handleFilterClose(fId)} key={fId}
color={f.color} onClose={() => this.handleFilterClose(fId)}
closable color={f.color}
> closable
{f.display} >
{f.display}
</Tag>
);
})}
{page.filters.length > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag> </Tag>
); )}
})} </div>
{page.filters.length > 1 && ( )}
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
)}
<List <List
className="Pageable-list" className="Pageable-list"

View File

@ -3,7 +3,19 @@ import moment from 'moment';
import { view } from 'react-easy-state'; import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router'; import { RouteComponentProps, withRouter } from 'react-router';
import { Link } from 'react-router-dom'; 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 Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form'; import { FormComponentProps } from 'antd/lib/form';
import { PROPOSAL_CATEGORY, RFP_STATUS, RFPArgs } from 'src/types'; 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" />; return <Exception type="404" desc="This RFP does not exist" />;
} }
} }
const dateCloses = isFieldsTouched(['dateCloses']) const dateCloses = isFieldsTouched(['dateCloses'])
? getFieldValue('dateCloses') ? getFieldValue('dateCloses')
: defaults.dateCloses && moment(defaults.dateCloses * 1000); : defaults.dateCloses && moment(defaults.dateCloses * 1000);
@ -100,7 +112,9 @@ class RFPForm extends React.Component<Props, State> {
{rfpId && ( {rfpId && (
<Form.Item <Form.Item
label="Status" 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', { {getFieldDecorator('status', {
initialValue: defaults.status, initialValue: defaults.status,
@ -214,10 +228,10 @@ class RFPForm extends React.Component<Props, State> {
help="Date that proposals will stop being submittable by" help="Date that proposals will stop being submittable by"
> >
{getFieldDecorator('dateCloses', { {getFieldDecorator('dateCloses', {
initialValue: defaults.dateCloses ? moment(defaults.dateCloses * 1000) : undefined, initialValue: defaults.dateCloses
})( ? moment(defaults.dateCloses * 1000)
<DatePicker size="large" /> : undefined,
)} })(<DatePicker size="large" />)}
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </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> <span className="nav-text">Emails</span>
</Link> </Link>
</Menu.Item> </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}> <Menu.Item key="logout" onClick={store.logout}>
<Icon type="logout" /> <Icon type="logout" />
<span className="nav-text">Logout</span> <span className="nav-text">Logout</span>

View File

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

View File

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

View File

@ -120,3 +120,24 @@ export const userFilters: Filters = {
list: USER_FILTERS, list: USER_FILTERS,
getById: getFilterById(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 flask_yoloapi import endpoint, parameter
from decimal import Decimal from decimal import Decimal
from datetime import datetime 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.email.send import generate_email, send_email
from grant.extensions import db from grant.extensions import db
from grant.proposal.models import ( from grant.proposal.models import (
@ -575,3 +575,47 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
db.session.commit() db.session.commit()
return proposal_contribution_schema.dump(contribution), 200 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 import datetime
from functools import reduce
from grant.extensions import ma, db 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 from sqlalchemy.orm import raiseload
HIDDEN_CONTENT = '~~comment removed by admin~~'
class Comment(db.Model): class Comment(db.Model):
__tablename__ = "comment" __tablename__ = "comment"
@ -11,6 +14,8 @@ class Comment(db.Model):
id = db.Column(db.Integer(), primary_key=True) id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime) date_created = db.Column(db.DateTime)
content = db.Column(db.Text, nullable=False) 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) 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) 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()) \ .order_by(Comment.date_created.desc()) \
.all() .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 CommentSchema(ma.Schema):
class Meta: class Meta:
@ -46,15 +69,23 @@ class CommentSchema(ma.Schema):
"content", "content",
"parent_comment_id", "parent_comment_id",
"date_created", "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") 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): def get_content(self, obj):
return dt_to_unix(obj.date_created) 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() comment_schema = CommentSchema()
@ -69,6 +100,8 @@ class UserCommentSchema(ma.Schema):
"proposal", "proposal",
"content", "content",
"date_created", "date_created",
"reported",
"hidden",
) )
proposal = ma.Nested( proposal = ma.Nested(
@ -78,14 +111,56 @@ class UserCommentSchema(ma.Schema):
"milestones", "milestones",
"content", "content",
"invites", "invites",
"updates" "updates",
] ]
) )
date_created = ma.Method("get_date_created")
def get_date_created(self, obj): content = ma.Method("get_content")
return dt_to_unix(obj.date_created) * 1000 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_comment_schema = UserCommentSchema()
user_comments_schema = UserCommentSchema(many=True) 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[]') filters_workaround = request.args.getlist('filters[]')
page = pagination.comment( page = pagination.comment(
schema=comments_schema, 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, page=page,
filters=filters_workaround, filters=filters_workaround,
search=search, search=search,
@ -79,6 +79,24 @@ def get_proposal_comments(proposal_id, page, filters, search, sort):
return page 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"]) @blueprint.route("/<proposal_id>/comments", methods=["POST"])
@requires_email_verified_auth @requires_email_verified_auth
@endpoint.api( @endpoint.api(

View File

@ -3,6 +3,7 @@ from sqlalchemy import or_, and_
from grant.comment.models import Comment, comments_schema from grant.comment.models import Comment, comments_schema
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_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.user.models import User, users_schema
from grant.milestone.models import Milestone from grant.milestone.models import Milestone
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage
@ -232,7 +233,7 @@ class UserPagination(Pagination):
class CommentPagination(Pagination): class CommentPagination(Pagination):
def __init__(self): def __init__(self):
self.FILTERS = [] self.FILTERS = ['REPORTED', 'HIDDEN']
self.PAGE_SIZE = 10 self.PAGE_SIZE = 10
self.SORT_MAP = { self.SORT_MAP = {
'CREATED:DESC': Comment.date_created.desc(), 'CREATED:DESC': Comment.date_created.desc(),
@ -253,18 +254,22 @@ class CommentPagination(Pagination):
# FILTER # FILTER
if filters: 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) # SORT (see self.SORT_MAP)
if sort: if sort:
self.validate_sort(sort) self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort]) query = query.order_by(self.SORT_MAP[sort])
# SEARCH can match txids or amounts # SEARCH
if search: if search:
query = query.filter(or_( query = query.filter(
Comment.content.ilike(f'%{search}%'), Comment.content.ilike(f'%{search}%')
)) )
res = query.paginate(page, self.PAGE_SIZE, False) res = query.paginate(page, self.PAGE_SIZE, False)
return { 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 }); 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) { export function getProposalUpdates(proposalId: number | string) {
return axios.get(`/api/v1/proposals/${proposalId}/updates`); return axios.get(`/api/v1/proposals/${proposalId}/updates`);
} }

View File

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

View File

@ -4,6 +4,7 @@ import {
getProposal, getProposal,
getProposalComments, getProposalComments,
getProposalUpdates, getProposalUpdates,
reportProposalComment as apiReportProposalComment,
getProposalContributions, getProposalContributions,
postProposalComment as apiPostProposalComment, postProposalComment as apiPostProposalComment,
requestProposalPayout, 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) => { export default (state = INITIAL_STATE, action: any) => {
const { payload } = action; const { payload } = action;
switch (action.type) { 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: case types.PROPOSAL_UPDATES_PENDING:
return { return {
...state, ...state,

View File

@ -47,6 +47,11 @@ enum proposalTypes {
PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED', PROPOSAL_PAYOUT_ACCEPT_FULFILLED = 'PROPOSAL_PAYOUT_ACCEPT_FULFILLED',
PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED', PROPOSAL_PAYOUT_ACCEPT_REJECTED = 'PROPOSAL_PAYOUT_ACCEPT_REJECTED',
PROPOSAL_PAYOUT_ACCEPT_PENDING = 'PROPOSAL_PAYOUT_ACCEPT_PENDING', 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; export default proposalTypes;

View File

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