Merge pull request #162 from grant-project/proposal-arbiter

Proposal arbiter
This commit is contained in:
Daniel Ternyak 2019-02-07 13:16:14 -06:00 committed by GitHub
commit 92bbbfe506
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 786 additions and 114 deletions

View File

@ -0,0 +1,14 @@
.ArbiterControl {
&-results {
margin: 1rem 0 0 0;
&.no-results {
padding: 2rem 0;
font-weight: bold;
color: rgba(0, 0, 0, 0.5);
text-align: center;
border: 1px dashed rgba(0, 0, 0, 0.2);
border-radius: 0.2rem;
}
}
}

View File

@ -0,0 +1,161 @@
import { debounce } from 'lodash';
import React from 'react';
import { view } from 'react-easy-state';
import { Button, Modal, Input, Icon, List, Avatar, message } from 'antd';
import store from 'src/store';
import { Proposal, User } from 'src/types';
import Search from 'antd/lib/input/Search';
import { ButtonProps } from 'antd/lib/button';
import './index.less';
interface OwnProps {
buttonProps?: ButtonProps;
}
type Props = OwnProps & Proposal;
const STATE = {
showSearch: false,
searching: false,
};
type State = typeof STATE;
class ArbiterControlNaked extends React.Component<Props, State> {
state = STATE;
searchInput: null | Search = null;
private searchArbiter = debounce(async search => {
await store.searchArbiters(search);
this.setState({ searching: false });
}, 1000);
render() {
const { arbiter } = this.props;
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
const showEmpty = !results.length && !searching;
return (
<>
{/* CONTROL */}
<Button
className="ArbiterControl-control"
loading={store.proposalDetailApproving}
icon="crown"
type="primary"
onClick={this.handleShowSearch}
{...this.props.buttonProps}
>
{arbiter ? 'Change arbiter' : 'Set arbiter'}
</Button>
{/* SEARCH MODAL */}
{showSearch && (
<Modal
title={
<>
<Icon type="crown" /> Select an arbiter
</>
}
visible={true}
footer={null}
onCancel={this.handleCloseSearch}
>
<>
<Input.Search
ref={x => (this.searchInput = x)}
placeholder="name or email"
onChange={this.handleSearchInputChange}
/>
{/* EMPTY RESULTS */}
{showEmpty && (
<div className={`ArbiterControl-results no-results`}>
{(!error && (
<>
no arbiters found {search && ` for "${search}"`}, please type search
query
</>
)) || (
<>
<Icon type="exclamation-circle" /> {error}
</>
)}
</div>
)}
{/* RESULTS */}
{!showEmpty && (
<div className="ArbiterControl-results">
<List
size="small"
loading={searching}
bordered
dataSource={results}
renderItem={(u: User) => (
<List.Item
actions={[
<Button
type="primary"
key="select"
onClick={() => this.handleSelect(u)}
>
Select
</Button>,
]}
>
<List.Item.Meta
avatar={
<Avatar
icon="user"
src={(u.avatar && u.avatar.imageUrl) || undefined}
/>
}
title={u.displayName}
description={u.emailAddress}
/>
</List.Item>
)}
/>
</div>
)}
</>
</Modal>
)}
</>
);
}
private handleShowSearch = () => {
this.setState({ showSearch: true });
// hacky way of waiting for modal to render in before focus
setTimeout(() => {
if (this.searchInput) this.searchInput.focus();
}, 200);
};
private handleSearchInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ searching: true });
const search = ev.currentTarget.value;
this.searchArbiter(search);
};
private handleSelect = async (user: User) => {
this.setState({ showSearch: false });
store.searchArbitersClear();
try {
await store.setArbiter(this.props.proposalId, user.userid);
message.success(
<>
Arbiter set for <b>{this.props.title}</b>
</>,
);
} catch (e) {
message.error(`Could not set arbiter: ${e}`);
}
};
private handleCloseSearch = () => {
this.setState({ showSearch: false });
store.searchArbitersClear();
};
}
const ArbiterControl = view(ArbiterControlNaked);
export default ArbiterControl;

View File

@ -6,7 +6,7 @@ import store from 'src/store';
import Pageable from 'components/Pageable';
import ContributionItem from './ContributionItem';
import { Contribution } from 'src/types';
import { PROPOSAL_STATUSES } from 'util/statuses';
import { contributionFilters } from 'util/filters';
class Contributions extends React.Component<{}> {
render() {
@ -16,7 +16,7 @@ class Contributions extends React.Component<{}> {
return (
<Pageable
page={page}
statuses={PROPOSAL_STATUSES}
filters={contributionFilters}
sorts={sorts}
searchPlaceholder="Search amount or txid"
controlsExtra={
@ -24,9 +24,7 @@ class Contributions extends React.Component<{}> {
<Button icon="plus">Create a contribution</Button>
</Link>
}
renderItem={(c: Contribution) =>
<ContributionItem key={c.id} contribution={c} />
}
renderItem={(c: Contribution) => <ContributionItem key={c.id} contribution={c} />}
handleSearch={store.fetchContributions}
handleChangeQuery={store.setContributionPageQuery}
handleResetQuery={store.resetContributionPageQuery}

View File

@ -72,4 +72,9 @@ export default [
title: 'Comment reply',
description: 'Sent if someone makes a direct reply to your comment',
},
{
id: 'proposal_arbiter',
title: 'Arbiter assignment',
description: 'Sent if someone is made arbiter of a proposal',
},
] as Email[];

View File

@ -7,6 +7,10 @@
margin-bottom: 3rem;
font-size: 1rem;
& > * + * {
margin-top: 1rem;
}
.anticon {
color: #ffaa00;
}

View File

@ -11,17 +11,41 @@ class Home extends React.Component {
}
render() {
const { userCount, proposalCount, proposalPendingCount } = store.stats;
const {
userCount,
proposalCount,
proposalPendingCount,
proposalNoArbiterCount,
} = store.stats;
const actionItems = [
!!proposalPendingCount && (
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
proposals <b>waiting for review</b>.{' '}
<Link to="/proposals?filters[]=STATUS_PENDING">Click here</Link> to view them.
</div>
),
!!proposalNoArbiterCount && (
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
live proposals <b>without an arbiter</b>.{' '}
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=OTHER_ARBITER">
Click here
</Link>{' '}
to view them.
</div>
),
].filter(Boolean);
return (
<div className="Home">
{!!proposalPendingCount && (
{!!actionItems.length && (
<div className="Home-actionItems">
<Divider orientation="left">Action Items</Divider>
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalPendingCount}</b>{' '}
proposals waiting for review.{' '}
<Link to="/proposals?status=PENDING">Click here</Link> to view them.
</div>
{actionItems.map((ai, i) => (
<div key={i}>{ai}</div>
))}
</div>
)}

View File

@ -5,12 +5,12 @@ import { Icon, Button, Dropdown, Menu, Tag, List, Input, Pagination } from 'antd
import { ClickParam } from 'antd/lib/menu';
import { RouteComponentProps, withRouter } from 'react-router';
import { PageData, PageQuery } from 'src/types';
import { StatusSoT, getStatusById } from 'util/statuses';
import { Filters } from 'util/filters';
import './index.less';
interface OwnProps<T> {
page: PageData<T>;
statuses: Array<StatusSoT<any>>;
filters: Filters;
sorts: string[];
searchPlaceholder?: string;
controlsExtra?: React.ReactNode;
@ -29,16 +29,20 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
}
render() {
const { page, statuses, sorts, renderItem, searchPlaceholder, controlsExtra } = this.props;
const {
page,
filters,
sorts,
renderItem,
searchPlaceholder,
controlsExtra,
} = this.props;
const loading = !page.fetched || page.fetching;
const filters = page.filters
.filter(f => f.startsWith('STATUS_'))
.map(f => f.replace('STATUS_', ''));
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
{statuses.map(s => (
<Menu.Item key={s.id}>{s.filterDisplay}</Menu.Item>
{filters.list.map(f => (
<Menu.Item key={f.id}>{f.display}</Menu.Item>
))}
</Menu>
);
@ -72,9 +76,7 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
<Button title="refresh" icon="reload" onClick={this.props.handleSearch} />
{controlsExtra && (
<div className="Pageable-controls-extra">
{controlsExtra}
</div>
<div className="Pageable-controls-extra">{controlsExtra}</div>
)}
</div>
@ -87,17 +89,20 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
{!!page.filters.length && (
<div className="Pageable-filters">
Filters:{' '}
{filters.map(sf => (
<Tag
key={sf}
onClose={() => this.handleFilterClose(sf)}
color={getStatusById(statuses, sf).tagColor}
closable
>
status: {sf}
</Tag>
))}
{filters.length > 1 && (
{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>
@ -127,17 +132,13 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
}
private setQueryFromUrl = () => {
const { history, statuses, match, handleResetQuery, handleChangeQuery } = this.props;
const parsed = qs.parse(history.location.search);
const { history, match, handleResetQuery, handleChangeQuery } = this.props;
const parsed = qs.parse(history.location.search, { arrayFormat: 'bracket' });
// status filter
if (parsed.status) {
if (getStatusById(statuses, parsed.status)) {
// here we reset to normal page query params, we might want
// to do this every time we load or leave the component
handleResetQuery();
handleChangeQuery({ filters: [`STATUS_${parsed.status}`] });
}
// filters
if (parsed.filters) {
handleResetQuery();
handleChangeQuery({ filters: parsed.filters });
history.replace(match.url); // remove qs
}
};
@ -150,14 +151,14 @@ class Pageable<T> extends React.Component<Props<T>, {}> {
private handleFilterClick = (e: ClickParam) => {
const { page, handleChangeQuery, handleSearch } = this.props;
handleChangeQuery({
filters: uniq([`STATUS_${e.key}`, ...page.filters])
filters: uniq([e.key, ...page.filters]),
});
handleSearch();
};
private handleFilterClose = (filter: string) => {
const { page, handleChangeQuery, handleSearch } = this.props;
handleChangeQuery({ filters: without(page.filters, `STATUS_${filter}`) });
handleChangeQuery({ filters: without(page.filters, filter) });
handleSearch();
};

View File

@ -5,6 +5,7 @@
&-controls {
&-control + &-control {
margin-left: 0 !important;
margin-top: 0.8rem;
}
}

View File

@ -21,6 +21,7 @@ import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
import Markdown from 'components/Markdown';
import ArbiterControl from 'components/ArbiterControl';
import './index.less';
type Props = RouteComponentProps<any>;
@ -47,7 +48,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return 'loading proposal...';
}
const renderDelete = () => (
const renderDeleteControl = () => (
<Popconfirm
onConfirm={this.handleDelete}
title="Delete proposal?"
@ -60,7 +61,18 @@ class ProposalDetailNaked extends React.Component<Props, State> {
</Popconfirm>
);
const renderMatching = () => (
const renderArbiterControl = () => (
<ArbiterControl
{...p}
buttonProps={{
type: 'default',
className: 'ProposalDetail-controls-control',
block: true,
}}
/>
);
const renderMatchingControl = () => (
<div className="ProposalDetail-controls-control">
<Popconfirm
overlayClassName="ProposalDetail-popover-overlay"
@ -197,6 +209,22 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderSetArbiter = () =>
!p.arbiter &&
p.status === PROPOSAL_STATUS.LIVE && (
<Alert
showIcon
type="warning"
message="No Arbiter on Live Proposal"
description={
<div>
<p>An arbiter is required to review milestone payout requests.</p>
<ArbiterControl {...p} />
</div>
}
/>
);
const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet">
<span>{name}</span>
@ -214,6 +242,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderApproved()}
{renderReview()}
{renderRejected()}
{renderSetArbiter()}
<Collapse defaultActiveKey={['brief', 'content']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
@ -234,8 +263,9 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Col span={6}>
{/* ACTIONS */}
<Card size="small" className="ProposalDetail-controls">
{renderDelete()}
{renderMatching()}
{renderDeleteControl()}
{renderArbiterControl()}
{renderMatchingControl()}
{/* TODO - other actions */}
</Card>
@ -249,9 +279,16 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
{p.arbiter &&
renderDeetItem(
'arbiter',
<Link to={`/users/${p.arbiter.userid}`}>{p.arbiter.displayName}</Link>,
)}
{p.rfp &&
renderDeetItem('rfp', <Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>)
}
renderDeetItem(
'rfp',
<Link to={`/rfps/${p.rfp.id}`}>{p.rfp.title}</Link>,
)}
</Card>
{/* TEAM */}

View File

@ -4,7 +4,7 @@ import store from 'src/store';
import ProposalItem from './ProposalItem';
import Pageable from 'components/Pageable';
import { Proposal } from 'src/types';
import { PROPOSAL_STATUSES } from 'util/statuses';
import { proposalFilters } from 'util/filters';
class Proposals extends React.Component<{}> {
render() {
@ -14,7 +14,7 @@ class Proposals extends React.Component<{}> {
return (
<Pageable
page={page}
statuses={PROPOSAL_STATUSES}
filters={proposalFilters}
sorts={sorts}
searchPlaceholder="Search proposal titles"
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}

View File

@ -57,6 +57,16 @@ async function deleteUser(id: number) {
return data;
}
async function fetchArbiters(search: string) {
const { data } = await api.get(`/admin/arbiters`, { params: { search } });
return data;
}
async function setArbiter(proposalId: number, userId: number) {
const { data } = await api.put(`/admin/arbiters`, { proposalId, userId });
return data;
}
async function fetchProposals(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/proposals', { params });
return data;
@ -132,6 +142,7 @@ async function editContribution(id: number, args: ContributionArgs) {
// STORE
const app = store({
/*** DATA ***/
hasCheckedLogin: false,
isLoggedIn: false,
loginError: '',
@ -142,6 +153,7 @@ const app = store({
userCount: 0,
proposalCount: 0,
proposalPendingCount: 0,
proposalNoArbiterCount: 0,
},
usersFetching: false,
@ -152,6 +164,13 @@ const app = store({
userDeleting: false,
userDeleted: false,
arbitersSearch: {
search: '',
results: [] as User[],
fetching: false,
error: null as string | null,
},
proposals: {
page: createDefaultPageData<Proposal>('CREATED:DESC'),
},
@ -195,6 +214,16 @@ const app = store({
}
},
updateUserInStore(u: User) {
const index = app.users.findIndex(x => x.userid === u.userid);
if (index > -1) {
app.users[index] = u;
}
if (app.userDetail && app.userDetail.userid === u.userid) {
app.userDetail = u;
}
},
// Auth
async checkLogin() {
@ -266,6 +295,42 @@ const app = store({
app.userDeleting = false;
},
// Arbiters
async searchArbiters(search: string) {
app.arbitersSearch = {
...app.arbitersSearch,
search,
fetching: true,
};
try {
const data = await fetchArbiters(search);
app.arbitersSearch = {
...app.arbitersSearch,
...data,
};
} catch (e) {
handleApiError(e);
}
app.arbitersSearch.fetching = false;
},
async searchArbitersClear() {
app.arbitersSearch = {
search: '',
results: [] as User[],
fetching: false,
error: null,
};
},
async setArbiter(proposalId: number, userId: number) {
// let component handle errors for this one
const { proposal, user } = await setArbiter(proposalId, userId);
this.updateProposalInStore(proposal);
this.updateUserInStore(user);
},
// Proposals
async fetchProposals() {
@ -284,6 +349,10 @@ const app = store({
},
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,
@ -438,6 +507,10 @@ const app = store({
},
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,
@ -445,7 +518,12 @@ const app = store({
},
getContributionPageQuery() {
return pick(app.contributions.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
return pick(app.contributions.page, [
'page',
'search',
'filters',
'sort',
]) as PageQuery;
},
resetContributionPageQuery() {
@ -487,7 +565,7 @@ const app = store({
handleApiError(e);
}
app.contributionSaving = false;
}
},
});
// Utils
@ -512,7 +590,7 @@ function createDefaultPageData<T>(sort: string): PageData<T> {
items: [] as T[],
fetching: false,
fetched: false,
}
};
}
// Attach to window for inspection

View File

@ -68,6 +68,7 @@ export interface Proposal {
rejectReason: string;
contributionMatching: number;
rfp?: RFP;
arbiter?: User;
}
export interface Comment {
commentId: string;

72
admin/src/util/filters.ts Normal file
View File

@ -0,0 +1,72 @@
import { PROPOSAL_STATUSES, RFP_STATUSES, CONTRIBUTION_STATUSES } from './statuses';
export interface Filter {
id: string;
display: string;
color: string;
group: string;
}
export interface Filters {
list: Filter[];
getById: (id: string) => Filter;
}
const getFilterById = (from: Filter[]) => (id: string) => {
const search = from.find(x => x.id === id);
if (!search) {
throw Error(`filter.getById: could not find filter for '${id}'`);
}
return search;
};
// Proposal
const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
id: `STATUS_${s.id}`,
display: `Status: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Status',
}))
// proposal has extra filters
.concat([
{
id: `OTHER_ARBITER`,
display: `Other: Arbiter`,
color: '#cf00d5',
group: 'Other',
},
]);
export const proposalFilters: Filters = {
list: PROPOSAL_FILTERS,
getById: getFilterById(PROPOSAL_FILTERS),
};
// RFP
const RFP_FILTERS = RFP_STATUSES.map(s => ({
id: `STATUS_${s.id}`,
display: `Status: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Status',
}));
export const rfpFilters: Filters = {
list: RFP_FILTERS,
getById: getFilterById(RFP_FILTERS),
};
// Contribution
const CONTRIBUTION_FILTERS = CONTRIBUTION_STATUSES.map(s => ({
id: `STATUS_${s.id}`,
display: `Status: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Status',
}));
export const contributionFilters: Filters = {
list: CONTRIBUTION_FILTERS,
getById: getFilterById(CONTRIBUTION_FILTERS),
};

View File

@ -2,7 +2,6 @@ import { PROPOSAL_STATUS, RFP_STATUS, CONTRIBUTION_STATUS } from 'src/types';
export interface StatusSoT<E> {
id: E;
filterDisplay: string;
tagDisplay: string;
tagColor: string;
hint: string;
@ -11,42 +10,36 @@ export interface StatusSoT<E> {
export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
{
id: PROPOSAL_STATUS.APPROVED,
filterDisplay: 'Status: approved',
tagDisplay: 'Approved',
tagColor: '#afd500',
hint: 'Proposal has been approved and is awaiting being published by user.',
},
{
id: PROPOSAL_STATUS.DELETED,
filterDisplay: 'Status: deleted',
tagDisplay: 'Deleted',
tagColor: '#bebebe',
hint: 'Proposal has been deleted and is not visible on the platform.',
},
{
id: PROPOSAL_STATUS.DRAFT,
filterDisplay: 'Status: draft',
tagDisplay: 'Draft',
tagColor: '#8d8d8d',
hint: 'Proposal is being created by the user.',
},
{
id: PROPOSAL_STATUS.LIVE,
filterDisplay: 'Status: live',
tagDisplay: 'Live',
tagColor: '#108ee9',
hint: 'Proposal is live on the platform.',
},
{
id: PROPOSAL_STATUS.PENDING,
filterDisplay: 'Status: pending',
tagDisplay: 'Awaiting Approval',
tagColor: '#ffaa00',
hint: 'User is waiting for admin to approve or reject this Proposal.',
},
{
id: PROPOSAL_STATUS.REJECTED,
filterDisplay: 'Status: rejected',
tagDisplay: 'Approval Rejected',
tagColor: '#eb4118',
hint:
@ -54,7 +47,6 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
},
{
id: PROPOSAL_STATUS.STAKING,
filterDisplay: 'Status: staking',
tagDisplay: 'Staking',
tagColor: '#722ed1',
hint: 'This proposal is awaiting a staking contribution.',
@ -64,21 +56,18 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
{
id: RFP_STATUS.DRAFT,
filterDisplay: 'Status: draft',
tagDisplay: 'Draft',
tagColor: '#ffaa00',
hint: 'RFP is currently being edited by admins and isnt visible to users.',
},
{
id: RFP_STATUS.LIVE,
filterDisplay: 'Status: live',
tagDisplay: 'Live',
tagColor: '#108ee9',
hint: 'RFP is live and users can submit proposals for it.',
},
{
id: RFP_STATUS.CLOSED,
filterDisplay: 'Status: closed',
tagDisplay: 'Closed',
tagColor: '#eb4118',
hint:
@ -89,26 +78,23 @@ export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
export const CONTRIBUTION_STATUSES: Array<StatusSoT<CONTRIBUTION_STATUS>> = [
{
id: CONTRIBUTION_STATUS.PENDING,
filterDisplay: 'Status: pending',
tagDisplay: 'Pending',
tagColor: '#ffaa00',
hint: 'Contribution is currently waiting to be sent and confirmed on chain',
},
{
id: CONTRIBUTION_STATUS.CONFIRMED,
filterDisplay: 'Status: confirmed',
tagDisplay: 'Confirmed',
tagColor: '#108ee9',
hint: 'Contribution was confirmed on chain with multiple block confirmations',
},
{
id: CONTRIBUTION_STATUS.DELETED,
filterDisplay: 'Status: deleted',
tagDisplay: 'Closed',
tagColor: '#eb4118',
hint: 'User deleted the contribution before it was sent or confirmed',
},
]
];
export function getStatusById<E>(statuses: Array<StatusSoT<E>>, id: E) {
const result = statuses.find(s => s.id === id);

View File

@ -99,4 +99,9 @@ example_email_args = {
'comment_url': 'http://somecomment.com',
'author_url': 'http://someuser.com',
},
'proposal_arbiter': {
'proposal': proposal,
'proposal_url': 'http://someproposal.com',
'arbitration_url': 'http://arbitrationtab.com',
}
}

View File

@ -3,7 +3,7 @@ from flask import Blueprint, request
from flask_yoloapi import endpoint, parameter
from decimal import Decimal
from grant.comment.models import Comment, user_comments_schema
from grant.email.send import generate_email
from grant.email.send import generate_email, send_email
from grant.extensions import db
from grant.proposal.models import (
Proposal,
@ -13,9 +13,10 @@ from grant.proposal.models import (
proposal_contribution_schema,
user_proposal_contributions_schema,
)
from grant.user.models import User, users_schema, user_schema
from grant.user.models import User, admin_users_schema, admin_user_schema
from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema
from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
from grant.utils.misc import make_url
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.utils import pagination
from sqlalchemy import func, or_
@ -59,10 +60,15 @@ def stats():
proposal_pending_count = db.session.query(func.count(Proposal.id)) \
.filter(Proposal.status == ProposalStatus.PENDING) \
.scalar()
proposal_no_arbiter_count = db.session.query(func.count(Proposal.id)) \
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(Proposal.arbiter_id == None) \
.scalar()
return {
"userCount": user_count,
"proposalCount": proposal_count,
"proposalPendingCount": proposal_pending_count,
"proposalNoArbiterCount": proposal_no_arbiter_count,
}
@ -87,7 +93,7 @@ def delete_user(user_id):
@admin_auth_required
def get_users():
users = User.query.all()
result = users_schema.dump(users)
result = admin_users_schema.dump(users)
return result
@ -97,7 +103,7 @@ def get_users():
def get_user(id):
user_db = User.query.filter(User.id == id).first()
if user_db:
user = user_schema.dump(user_db)
user = admin_user_schema.dump(user_db)
user_proposals = Proposal.query.filter(Proposal.team.any(id=user['userid'])).all()
user['proposals'] = proposals_schema.dump(user_proposals)
user_comments = Comment.get_by_user(user_db)
@ -109,6 +115,64 @@ def get_user(id):
return {"message": f"Could not find user with id {id}"}, 404
# ARBITERS
@blueprint.route("/arbiters", methods=["GET"])
@endpoint.api(
parameter('search', type=str, required=False),
)
@admin_auth_required
def get_arbiters(search):
results = []
error = None
if len(search) < 3:
error = 'search query must be at least 3 characters long'
else:
users = User.query.filter(
User.email_address.ilike(f'%{search}%') | User.display_name.ilike(f'%{search}%')
).order_by(User.display_name).all()
results = admin_users_schema.dump(users)
return {
'results': results,
'search': search,
'error': error
}
@blueprint.route('/arbiters', methods=['PUT'])
@endpoint.api(
parameter('proposalId', type=int, required=True),
parameter('userId', type=int, required=True)
)
@admin_auth_required
def set_arbiter(proposal_id, user_id):
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
return {"message": "Proposal not found"}, 404
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "User not found"}, 404
if proposal.arbiter_id != user.id:
# send email
send_email(user.email_address, 'proposal_arbiter', {
'proposal': proposal,
'proposal_url': make_url(f'/proposals/{proposal.id}'),
'arbitration_url': make_url(f'/profile/{user.id}?tab=arbitration'),
})
proposal.arbiter_id = user.id
db.session.add(proposal)
db.session.commit()
return {
'proposal': proposal_schema.dump(proposal),
'user': admin_user_schema.dump(user)
}, 200
# PROPOSALS
@ -382,8 +446,7 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
# Transaction ID (no validation)
if tx_id:
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200

View File

@ -154,6 +154,15 @@ def comment_reply(email_args):
}
def proposal_arbiter(email_args):
return {
'subject': f'You are now arbiter of {email_args["proposal"].title}',
'title': f'You are an Arbiter',
'preview': f'Congratulations, you have been promoted to arbiter of {email_args["proposal"].title}!',
'subscription': EmailSubscription.ARBITER,
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info,
@ -169,6 +178,7 @@ get_info_lookup = {
'contribution_confirmed': contribution_confirmed,
'contribution_update': contribution_update,
'comment_reply': comment_reply,
'proposal_arbiter': proposal_arbiter
}

View File

@ -49,6 +49,10 @@ class EmailSubscription(Enum):
'bit': 10,
'key': 'funded_proposal_payout_request'
}
ARBITER = {
'bit': 11,
'key': 'arbiter'
}
def is_email_sub_key(k: str):

View File

@ -13,34 +13,6 @@ from grant.utils.requests import blockchain_get
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus
from grant.settings import PROPOSAL_STAKING_AMOUNT
# Proposal states
DRAFT = 'DRAFT'
PENDING = 'PENDING'
STAKING = 'STAKING'
APPROVED = 'APPROVED'
REJECTED = 'REJECTED'
LIVE = 'LIVE'
DELETED = 'DELETED'
STATUSES = [DRAFT, PENDING, STAKING, APPROVED, REJECTED, LIVE, DELETED]
# Funding stages
FUNDING_REQUIRED = 'FUNDING_REQUIRED'
COMPLETED = 'COMPLETED'
PROPOSAL_STAGES = [FUNDING_REQUIRED, COMPLETED]
# Proposal categories
DAPP = "DAPP"
DEV_TOOL = "DEV_TOOL"
CORE_DEV = "CORE_DEV"
COMMUNITY = "COMMUNITY"
DOCUMENTATION = "DOCUMENTATION"
ACCESSIBILITY = "ACCESSIBILITY"
CATEGORIES = [DAPP, DEV_TOOL, CORE_DEV, COMMUNITY, DOCUMENTATION, ACCESSIBILITY]
# Contribution states
# PENDING = 'PENDING'
CONFIRMED = 'CONFIRMED'
proposal_team = db.Table(
'proposal_team', db.Model.metadata,
db.Column('user_id', db.Integer, db.ForeignKey('user.id')),
@ -184,6 +156,7 @@ class Proposal(db.Model):
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
arbiter_id = db.Column(db.Integer(), db.ForeignKey('user.id'), nullable=True)
# Content info
status = db.Column(db.String(255), nullable=False)
@ -210,6 +183,7 @@ class Proposal(db.Model):
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
milestones = db.relationship("Milestone", backref="proposal", lazy=True, cascade="all, delete-orphan")
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
arbiter = db.relationship("User", lazy=True, back_populates="arbitrated_proposals")
def __init__(
self,
@ -322,7 +296,7 @@ class Proposal(db.Model):
# find pending contribution for any user of remaining amount
contribution = ProposalContribution.query.filter_by(
proposal_id=self.id,
status=PENDING,
status=ProposalStatus.PENDING,
).first()
if not contribution:
contribution = self.create_contribution(user_id, str(remaining.normalize()))
@ -431,7 +405,8 @@ class ProposalSchema(ma.Schema):
"deadline_duration",
"contribution_matching",
"invites",
"rfp"
"rfp",
"arbiter"
)
date_created = ma.Method("get_date_created")
@ -445,6 +420,7 @@ class ProposalSchema(ma.Schema):
milestones = ma.Nested("MilestoneSchema", many=True)
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
arbiter = ma.Nested("UserSchema") # exclude=["arbitrated_proposals"])
def get_proposal_id(self, obj):
return obj.id

View File

@ -0,0 +1,32 @@
<p style="margin: 0 0 20px;">
You have been made arbiter of
<a href="{{ args.proposal_url }}" target="_blank">
{{ args.proposal.title }} </a
>. You will be responsible for reviewing milestone payout requests.
</p>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px;"
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.arbitration_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
View your arbitrations
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,4 @@
You have been made arbiter of {{ args.proposal.title }}. You will be responsible
for reviewing milestone payout requests.
View your arbitrations: {{ args.arbitration_url }}

View File

@ -18,7 +18,7 @@ from sqlalchemy.ext.hybrid import hybrid_property
def is_current_authed_user_id(user_id):
return current_user.is_authenticated and \
current_user.id == user_id
current_user.id == user_id
class RolesUsers(db.Model):
@ -118,6 +118,7 @@ class User(db.Model, UserMixin):
lazy=True, cascade="all, delete-orphan")
roles = db.relationship('Role', secondary='roles_users',
backref=db.backref('users', lazy='dynamic'))
arbitrated_proposals = db.relationship("Proposal", lazy=True, back_populates="arbiter")
# TODO - add create and validate methods
@ -235,13 +236,15 @@ class SelfUserSchema(ma.Schema):
"avatar",
"display_name",
"userid",
"email_verified"
"email_verified",
"arbitrated_proposals"
)
social_medias = ma.Nested("SocialMediaSchema", many=True)
avatar = ma.Nested("AvatarSchema")
userid = ma.Method("get_userid")
email_verified = ma.Method("get_email_verified")
arbitrated_proposals = ma.Nested("ProposalSchema", many=True, exclude=["arbiter"])
def get_userid(self, obj):
return obj.id
@ -253,6 +256,10 @@ class SelfUserSchema(ma.Schema):
self_user_schema = SelfUserSchema()
self_users_schema = SelfUserSchema(many=True)
# differentiate from self, same for now
admin_user_schema = self_user_schema
admin_users_schema = self_users_schema
class UserSchema(ma.Schema):
class Meta:

View File

@ -65,13 +65,15 @@ def get_me():
parameter("withProposals", type=bool, required=False),
parameter("withComments", type=bool, required=False),
parameter("withFunded", type=bool, required=False),
parameter("withPending", type=bool, required=False)
parameter("withPending", type=bool, required=False),
parameter("withArbitrated", type=bool, required=False)
)
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, with_arbitrated):
user = User.get_by_id(user_id)
if user:
result = user_schema.dump(user)
authed_user = get_authed_user()
is_self = authed_user and authed_user.id == user.id
if with_proposals:
proposals = Proposal.get_by_user(user)
proposals_dump = user_proposals_schema.dump(proposals)
@ -86,7 +88,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
comments = Comment.get_by_user(user)
comments_dump = user_comments_schema.dump(comments)
result["comments"] = comments_dump
if with_pending and authed_user and authed_user.id == user.id:
if with_pending and is_self:
pending = Proposal.get_by_user(user, [
ProposalStatus.STAKING,
ProposalStatus.PENDING,
@ -95,6 +97,8 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending):
])
pending_dump = user_proposals_schema.dump(pending)
result["pendingProposals"] = pending_dump
if with_arbitrated and is_self:
result["arbitrated"] = user_proposals_schema.dump(user.arbitrated_proposals)
return result
else:
message = "User with id matching {} not found".format(user_id)

View File

@ -49,6 +49,7 @@ class ProposalPagination(Pagination):
self.FILTERS = [f'STATUS_{s}' for s in ProposalStatus.list()]
self.FILTERS.extend([f'STAGE_{s}' for s in ProposalStage.list()])
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
self.FILTERS.extend(['OTHER_ARBITER'])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': Proposal.date_created.desc(),
@ -75,6 +76,7 @@ class ProposalPagination(Pagination):
status_filters = extract_filters('STATUS_', filters)
stage_filters = extract_filters('STAGE_', filters)
cat_filters = extract_filters('CAT_', filters)
other_filters = extract_filters('OTHER_', filters)
if status_filters:
query = query.filter(Proposal.status.in_(status_filters))
@ -84,6 +86,8 @@ class ProposalPagination(Pagination):
# query = query.filter(Proposal.stage.in_(stage_filters))
if cat_filters:
query = query.filter(Proposal.category.in_(cat_filters))
if other_filters:
query = query.filter(Proposal.arbiter_id == None)
# SORT (see self.SORT_MAP)
if sort:

View File

@ -0,0 +1,30 @@
"""empty message
Revision ID: 310dca400b81
Revises: fa1fedf4ca08
Create Date: 2019-02-05 11:24:11.291158
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '310dca400b81'
down_revision = 'fa1fedf4ca08'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('proposal', sa.Column('arbiter_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'proposal', 'user', ['arbiter_id'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'proposal', type_='foreignkey')
op.drop_column('proposal', 'arbiter_id')
# ### end Alembic commands ###

View File

@ -68,6 +68,7 @@ export function getUser(address: string): Promise<{ data: User }> {
withComments: true,
withFunded: true,
withPending: true,
withArbitrated: true,
},
})
.then(res => {

View File

@ -0,0 +1,71 @@
@import '~styles/variables.less';
@small-query: ~'(max-width: 640px)';
.ProfileArbitrated {
display: flex;
padding-bottom: 1.2rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 1rem;
&:last-child {
border-bottom: none;
padding-bottom: none;
}
@media @small-query {
flex-direction: column;
padding-bottom: 0.6rem;
}
&-title {
font-size: 1.2rem;
font-weight: 600;
color: inherit;
display: block;
margin-bottom: 0.1rem;
}
&-block {
flex: 1 0 0%;
&:last-child {
margin-left: 1.2rem;
flex: 0 0 0%;
min-width: 15rem;
@media @small-query {
margin-left: 0;
margin-top: 0.6rem;
}
}
&.is-actions {
display: flex;
justify-content: flex-end;
align-items: center;
& button + button,
a + button {
margin-left: 0.5rem;
}
}
}
.ant-tag {
vertical-align: text-top;
}
&-status {
margin-bottom: 0.6rem;
& q {
display: block;
margin: 0.5rem;
font-style: italic;
}
& small {
opacity: 0.6;
}
}
}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { UserProposal } from 'types';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
import './ProfileArbitrated.less';
interface OwnProps {
proposal: UserProposal;
}
interface StateProps {
user: AppState['auth']['user'];
}
type Props = OwnProps & StateProps;
class ProfileArbitrated extends React.Component<Props, {}> {
render() {
const { title, proposalId } = this.props.proposal;
return (
<div className="ProfileArbitrated">
<div className="ProfileArbitrated-block">
<Link to={`/proposals/${proposalId}`} className="ProfileArbitrated-title">
{title}
</Link>
<div className={`ProfileArbitrated-info`}>
You are the arbiter for this proposal. You are responsible for reviewing
milestone payout requests.
</div>
</div>
<div className="ProfileArbitrated-block is-actions">
{/* TODO - review milestone button & etc. */}
</div>
</div>
);
}
}
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
user: state.auth.user,
}))(ProfileArbitrated);

View File

@ -26,6 +26,7 @@ import ContributionModal from 'components/ContributionModal';
import LinkableTabs from 'components/LinkableTabs';
import './style.less';
import { UserContribution } from 'types';
import ProfileArbitrated from './ProfileArbitrated';
interface StateProps {
usersMap: AppState['users']['map'];
@ -86,11 +87,19 @@ class Profile extends React.Component<Props, State> {
return <ExceptionPage code="404" desc="No user could be found" />;
}
const { proposals, pendingProposals, contributions, comments, invites } = user;
const {
proposals,
pendingProposals,
contributions,
comments,
invites,
arbitrated,
} = user;
const nonePending = pendingProposals.length === 0;
const noneCreated = proposals.length === 0;
const noneFunded = contributions.length === 0;
const noneCommented = comments.length === 0;
const noneArbitrated = arbitrated.length === 0;
const noneInvites = user.hasFetchedInvites && invites.length === 0;
return (
@ -185,6 +194,22 @@ class Profile extends React.Component<Props, State> {
</div>
</Tabs.TabPane>
)}
{isAuthedUser && (
<Tabs.TabPane
tab={TabTitle('Arbitrations', arbitrated.length)}
key="arbitrations"
>
{noneArbitrated && (
<Placeholder
title="No arbitrations"
subtitle="You are not an arbiter of any proposals"
/>
)}
{arbitrated.map(arb => (
<ProfileArbitrated key={arb.proposalId} proposal={arb} />
))}
</Tabs.TabPane>
)}
</LinkableTabs>
</div>

View File

@ -20,6 +20,7 @@ export interface UserState extends User {
isUpdating: boolean;
updateError: string | null;
pendingProposals: UserProposal[];
arbitrated: UserProposal[];
proposals: UserProposal[];
contributions: UserContribution[];
comments: UserComment[];
@ -51,6 +52,7 @@ export const INITIAL_USER_STATE: UserState = {
isUpdating: false,
updateError: null,
pendingProposals: [],
arbitrated: [],
proposals: [],
contributions: [],
comments: [],

View File

@ -29,6 +29,9 @@ export function formatUserFromGet(user: UserState) {
if (user.pendingProposals) {
user.pendingProposals = user.pendingProposals.map(bnUserProp);
}
if (user.arbitrated) {
user.arbitrated = user.arbitrated.map(bnUserProp);
}
user.proposals = user.proposals.map(bnUserProp);
user.contributions = user.contributions.map(c => {
c.amount = toZat((c.amount as any) as string);

View File

@ -13,6 +13,11 @@ export const EMAIL_SUBSCRIPTIONS: { [key in ESKey]: EmailSubscriptionInfo } = {
category: EMAIL_SUBSCRIPTION_CATEGORY.GENERAL,
value: false,
},
arbiter: {
description: 'arbitration',
category: EMAIL_SUBSCRIPTION_CATEGORY.GENERAL,
value: false,
},
// FUNDED
fundedProposalCanceled: {

View File

@ -11,6 +11,7 @@ export interface EmailSubscriptions {
myProposalContribution: boolean;
myProposalFunded: boolean;
myProposalRefund: boolean;
arbiter: boolean;
}
export enum EMAIL_SUBSCRIPTION_CATEGORY {