From e8d6f659e848e7b5614b1e9f534a1e7a8eba0088 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 17 Feb 2019 13:15:40 -0600 Subject: [PATCH 1/8] basic comment pagination + admin moderation --- admin/src/Routes.tsx | 2 + .../components/Moderation/ModerationItem.less | 17 +++++ .../components/Moderation/ModerationItem.tsx | 55 ++++++++++++++ admin/src/components/Moderation/index.tsx | 29 +++++++ admin/src/components/Template/index.tsx | 6 ++ admin/src/store.ts | 76 +++++++++++++++++++ admin/src/types.ts | 5 +- backend/grant/admin/views.py | 25 +++++- backend/grant/comment/models.py | 49 +++++++++--- backend/grant/utils/pagination.py | 63 ++++++++++++++- 10 files changed, 314 insertions(+), 13 deletions(-) create mode 100644 admin/src/components/Moderation/ModerationItem.less create mode 100644 admin/src/components/Moderation/ModerationItem.tsx create mode 100644 admin/src/components/Moderation/index.tsx diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 3a670359..69dcf36b 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -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 { + )} diff --git a/admin/src/components/Moderation/ModerationItem.less b/admin/src/components/Moderation/ModerationItem.less new file mode 100644 index 00000000..e7d778bc --- /dev/null +++ b/admin/src/components/Moderation/ModerationItem.less @@ -0,0 +1,17 @@ +.ProposalItem { + & h2 { + font-size: 1.4rem; + margin-bottom: 0; + + & .ant-tag { + vertical-align: text-top; + margin-top: 0.2rem; + margin-left: 0.5rem; + } + } + + & p { + color: rgba(#000, 0.5); + margin: 0; + } +} diff --git a/admin/src/components/Moderation/ModerationItem.tsx b/admin/src/components/Moderation/ModerationItem.tsx new file mode 100644 index 00000000..90ea8daa --- /dev/null +++ b/admin/src/components/Moderation/ModerationItem.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import { Popconfirm, Tag, Tooltip, List } from 'antd'; +import { Link } from 'react-router-dom'; +import store from 'src/store'; +import { Proposal } from 'src/types'; +import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses'; +import { formatDateSeconds } from 'util/time'; +import './ProposalItem.less'; + +class ProposalItemNaked extends React.Component { + state = { + showDelete: false, + }; + render() { + const p = this.props; + const status = getStatusById(PROPOSAL_STATUSES, p.status); + const actions = [ + + delete + , + ]; + + return ( + + +

+ {p.title || '(no title)'} + + {status.tagDisplay} + +

+

Created: {formatDateSeconds(p.dateCreated)}

+

{p.brief}

+ {p.rfp && ( +

Submitted for RFP: {p.rfp.title}

+ )} + +
+ ); + } + private handleDelete = () => { + store.deleteProposal(this.props.proposalId); + }; +} + +const ProposalItem = view(ProposalItemNaked); +export default ProposalItem; diff --git a/admin/src/components/Moderation/index.tsx b/admin/src/components/Moderation/index.tsx new file mode 100644 index 00000000..d12d1167 --- /dev/null +++ b/admin/src/components/Moderation/index.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import store from 'src/store'; +// import ProposalItem from './ProposalItem'; +import Pageable from 'components/Pageable'; +import { Comment } from 'src/types'; +import { proposalFilters } from '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', 'PUBLISHED:DESC', 'PUBLISHED:ASC']; + return ( +
{p.content}
} + handleSearch={store.fetchComments} + handleChangeQuery={store.setCommentPageParams} + handleResetQuery={store.resetCommentPageParams} + /> + ); + } +} + +export default view(Moderation); diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index a1e8cb4d..4a7f0205 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -69,6 +69,12 @@ class Template extends React.Component { Emails + + + + Moderation + + Logout diff --git a/admin/src/store.ts b/admin/src/store.ts index 67eb82a6..a842b44a 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -100,6 +100,11 @@ async function approveProposal(id: number, isApprove: boolean, rejectReason?: st return data; } +async function fetchComments(params: Partial) { + const { data } = await api.get('/admin/comments', { params }); + return data; +} + async function markMilestonePaid(proposalId: number, milestoneId: number, txId: string) { const { data } = await api.put( `/admin/proposals/${proposalId}/milestone/${milestoneId}/paid`, @@ -200,6 +205,10 @@ const app = store({ proposalDetailApproving: false, proposalDetailMarkingMilestonePaid: false, + comments: { + page: createDefaultPageData('CREATED:DESC'), + }, + rfps: [] as RFP[], rfpsFetching: false, rfpsFetched: false, @@ -505,6 +514,19 @@ const app = store({ app.proposalDetailMarkingMilestonePaid = false; }, + // Comments + + fetchComments: makePageFetch( + () => app.comments.page, + p => (app.comments.page = p), + fetchComments, + ), + setCommentPageParams: makeSetPageParams( + () => app.comments.page, + p => (app.comments.page = p), + ), + resetCommentPageParams: makeResetPageParams(() => app.comments.page), + // Email async getEmailExample(type: string) { @@ -674,6 +696,60 @@ function createDefaultPageData(sort: string): PageData { }; } +type FNGetPage = () => PageData; +type FNSetPage = (p: PageData) => void; +type FNFetchPage = (params: PageQuery) => Promise; + +function makePageFetch( + getPage: FNGetPage, + setPage: FNSetPage, + fetch: FNFetchPage, +) { + return async () => { + let page = getPage(); + page.fetching = true; + try { + const params = getPageParams(page); + const newPage = await fetch(params); + page = { + ...page, + ...newPage, + fetched: true, + }; + } catch (e) { + handleApiError(e); + } + page.fetching = false; + setPage(page); + }; +} + +function getPageParams(page: PageData) { + return pick(page, ['page', 'search', 'filters', 'sort']) as PageQuery; +} + +function makeSetPageParams(getPage: FNGetPage, setPage: FNSetPage) { + return (query: Partial) => { + // sometimes we need to reset page to 1 + if (query.filters || query.search) { + query.page = 1; + } + setPage({ + ...getPage(), + ...query, + }); + }; +} + +function makeResetPageParams(getPage: FNGetPage) { + return () => { + getPage().page = 1; + getPage().search = ''; + getPage().sort = 'CREATED:DESC'; + getPage().filters = []; + }; +} + // Attach to window for inspection (window as any).appStore = app; diff --git a/admin/src/types.ts b/admin/src/types.ts index 1b7bf6ca..b6f90039 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -104,7 +104,6 @@ export interface Proposal { currentMilestone?: Milestone; team: User[]; comments: Comment[]; - contractStatus: string; target: string; contributed: string; funded: string; @@ -114,7 +113,9 @@ export interface Proposal { arbiter: ProposalArbiter; } export interface Comment { - commentId: string; + id: number; + userId: User['userid']; + author?: User; proposalId: Proposal['proposalId']; proposal?: Proposal; dateCreated: number; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 15f49ac4..e419de0e 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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 from grant.email.send import generate_email, send_email from grant.extensions import db from grant.proposal.models import ( @@ -575,3 +575,26 @@ 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 diff --git a/backend/grant/comment/models.py b/backend/grant/comment/models.py index f4509e51..935ca9d8 100644 --- a/backend/grant/comment/models.py +++ b/backend/grant/comment/models.py @@ -1,7 +1,7 @@ import datetime 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 @@ -49,13 +49,10 @@ class CommentSchema(ma.Schema): "replies" ) - date_created = ma.Method("get_date_created") + date_created = UnixDate(attribute='date_created') author = ma.Nested("UserSchema") replies = ma.Nested("CommentSchema", many=True) - def get_date_created(self, obj): - return dt_to_unix(obj.date_created) - comment_schema = CommentSchema() comments_schema = CommentSchema(many=True) @@ -82,11 +79,45 @@ class UserCommentSchema(ma.Schema): "updates" ] ) - date_created = ma.Method("get_date_created") - - def get_date_created(self, obj): - return dt_to_unix(obj.date_created) * 1000 + date_created = UnixDate(attribute='date_created') 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", + ) + + proposal = ma.Nested( + "ProposalSchema", + only=[ + "proposal_id", + "title", + "brief" + ] + ) + author = ma.Nested( + "SelfUserSchema", + only=[ + "userid", + "email_address", + "display_name", + "title" + ] + ) + date_created = UnixDate(attribute='date_created') + + +admin_comment_schema = AdminCommentSchema() +admin_comments_schema = AdminCommentSchema(many=True) diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 680bd46b..fca4672b 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -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.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 @@ -231,8 +232,68 @@ class UserPagination(Pagination): } +class CommentPagination(Pagination): + def __init__(self): + self.FILTERS = ['REPORTED', 'HIDDEN'] + self.PAGE_SIZE = 10 + self.SORT_MAP = { + 'CREATED:DESC': Comment.date_created.desc(), + 'CREATED:ASC': Comment.date_created, + # 'AUTHOR:DESC': Comment.author.display_name.desc(), + # 'AUTHOR:ASC': Comment.author.display_name, + # 'PROPOSAL:DESC': Comment.proposal.title.desc(), + # 'PROPOSAL:ASC': Comment.proposal.title, + } + + def paginate( + self, + schema: ma.Schema=users_schema, + query: db.Query=None, + page: int=1, + filters: list=None, + search: str=None, + sort: str='CREATED:DESC', + ): + query = query or Comment.query + sort = sort or 'CREATED: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( + Comment.content.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 +comment = CommentPagination().paginate user = UserPagination().paginate From fe908f449a6b7a9934624e135819cba011a05c47 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sun, 17 Feb 2019 14:20:55 -0600 Subject: [PATCH 2/8] Pageable: handle null filters --- admin/src/components/Pageable/index.tsx | 61 +++++++++++++------------ 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/admin/src/components/Pageable/index.tsx b/admin/src/components/Pageable/index.tsx index b562c40f..accbbbf4 100644 --- a/admin/src/components/Pageable/index.tsx +++ b/admin/src/components/Pageable/index.tsx @@ -10,7 +10,7 @@ import './index.less'; interface OwnProps { page: PageData; - filters: Filters; + filters: null | Filters; sorts: string[]; searchPlaceholder?: string; controlsExtra?: React.ReactNode; @@ -39,7 +39,7 @@ class Pageable extends React.Component, {}> { } = this.props; const loading = !page.fetched || page.fetching; - const statusFilterMenu = ( + const statusFilterMenu = filters && ( {filters.list.map(f => ( {f.display} @@ -63,11 +63,13 @@ class Pageable extends React.Component, {}> { placeholder={searchPlaceholder} onSearch={this.handleSearch} /> - - - + {filters && ( + + + + )}