Merge pull request #162 from grant-project/proposal-arbiter
Proposal arbiter
This commit is contained in:
commit
92bbbfe506
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -7,6 +7,10 @@
|
|||
margin-bottom: 3rem;
|
||||
font-size: 1rem;
|
||||
|
||||
& > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.anticon {
|
||||
color: #ffaa00;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
&-controls {
|
||||
&-control + &-control {
|
||||
margin-left: 0 !important;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -68,6 +68,7 @@ export interface Proposal {
|
|||
rejectReason: string;
|
||||
contributionMatching: number;
|
||||
rfp?: RFP;
|
||||
arbiter?: User;
|
||||
}
|
||||
export interface Comment {
|
||||
commentId: string;
|
||||
|
|
|
@ -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),
|
||||
};
|
|
@ -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 isn’t 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);
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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 }}
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 ###
|
|
@ -68,6 +68,7 @@ export function getUser(address: string): Promise<{ data: User }> {
|
|||
withComments: true,
|
||||
withFunded: true,
|
||||
withPending: true,
|
||||
withArbitrated: true,
|
||||
},
|
||||
})
|
||||
.then(res => {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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: [],
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -11,6 +11,7 @@ export interface EmailSubscriptions {
|
|||
myProposalContribution: boolean;
|
||||
myProposalFunded: boolean;
|
||||
myProposalRefund: boolean;
|
||||
arbiter: boolean;
|
||||
}
|
||||
|
||||
export enum EMAIL_SUBSCRIPTION_CATEGORY {
|
||||
|
|
Loading…
Reference in New Issue