Merge pull request #161 from grant-project/contribution-admin

Contribution Admin
This commit is contained in:
AMStrix 2019-02-07 09:42:34 -06:00 committed by GitHub
commit 136c1ee763
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 1036 additions and 182 deletions

View File

@ -15,6 +15,9 @@ import ProposalDetail from 'components/ProposalDetail';
import RFPs from 'components/RFPs';
import RFPForm from 'components/RFPForm';
import RFPDetail from 'components/RFPDetail';
import Contributions from 'components/Contributions';
import ContributionForm from 'components/ContributionForm';
import ContributionDetail from 'components/ContributionDetail';
import 'styles/style.less';
@ -41,6 +44,10 @@ class Routes extends React.Component<Props> {
<Route path="/rfps/:id/edit" component={RFPForm} />
<Route path="/rfps/:id" component={RFPDetail} />
<Route path="/rfps" component={RFPs} />
<Route path="/contributions/new" component={ContributionForm} />
<Route path="/contributions/:id/edit" component={ContributionForm} />
<Route path="/contributions/:id" component={ContributionDetail} />
<Route path="/contributions" component={Contributions} />
<Route path="/emails/:type?" component={Emails} />
</Switch>
)}

View File

@ -0,0 +1,32 @@
.ContributionDetail {
&-controls {
&-control + &-control {
margin-top: 0.8rem;
}
}
&-deet {
position: relative;
margin-bottom: 0.3rem;
overflow: auto;
&-value {
overflow: auto;
}
&-label {
font-size: 0.7rem;
opacity: 0.8;
}
}
& .ant-card,
.ant-alert,
.ant-collapse {
margin-bottom: 16px;
button + button {
margin-left: 0.5rem;
}
}
}

View File

@ -0,0 +1,95 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Row, Col, Card, Button, Collapse, Input } from 'antd';
import store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import UserItem from 'components/Users/UserItem';
import ProposalItem from 'components/Proposals/ProposalItem';
import './index.less';
type Props = RouteComponentProps<any>;
class ContributionDetail extends React.Component<Props> {
componentDidMount() {
store.fetchContributionDetail(this.getIdFromQuery());
}
render() {
const id = this.getIdFromQuery();
const { contributionDetail: c, contributionDetailFetching } = store;
if (!c || (c && c.id !== id) || contributionDetailFetching) {
return 'loading proposal...';
}
const renderDeetItem = (label: string, val: React.ReactNode) => (
<div className="ContributionDetail-deet">
<div className="ContributionDetail-deet-value">
{val}
</div>
<div className="ContributionDetail-deet-label">
{label}
</div>
</div>
);
return (
<div className="ContributionDetail">
<Back to="/contributions" text="Contributions" />
<Row gutter={16}>
{/* MAIN */}
<Col span={18}>
<Collapse defaultActiveKey={['addresses', 'user', 'proposal']}>
<Collapse.Panel key="addresses" header="addresses">
<pre>{JSON.stringify(c.addresses, null, 4)}</pre>
</Collapse.Panel>
<Collapse.Panel key="user" header="user">
<UserItem {...c.user} />
</Collapse.Panel>
<Collapse.Panel key="proposal" header="proposal">
<ProposalItem {...c.proposal} />
</Collapse.Panel>
<Collapse.Panel key="json" header="json">
<pre>{JSON.stringify(c, null, 4)}</pre>
</Collapse.Panel>
</Collapse>
</Col>
{/* RIGHT SIDE */}
<Col span={6}>
{/* ACTIONS */}
<Card size="small" className="ContributionDetail-controls">
<Link to={`/contributions/${id}/edit`}>
<Button type="primary" block>Edit</Button>
</Link>
</Card>
{/* DETAILS */}
<Card title="Details" size="small">
{renderDeetItem('id', c.id)}
{renderDeetItem('created', formatDateSeconds(c.dateCreated))}
{renderDeetItem('status', c.status)}
{renderDeetItem('amount', c.amount)}
{renderDeetItem('txid', c.txId
? <Input size="small" value={c.txId} readOnly />
: <em>N/A</em>
)}
</Card>
</Col>
</Row>
</div>
);
}
private getIdFromQuery = () => {
return Number(this.props.match.params.id);
};
}
export default withRouter(view(ContributionDetail));

View File

@ -0,0 +1,9 @@
.ContributionForm {
&-buttons {
margin-top: 2rem;
.ant-btn {
margin-right: 0.5rem;
}
}
}

View File

@ -0,0 +1,170 @@
import React from 'react';
import { view } from 'react-easy-state';
import { RouteComponentProps, withRouter } from 'react-router';
import { Form, Input, Button, message, Spin } from 'antd';
import Exception from 'ant-design-pro/lib/Exception';
import { FormComponentProps } from 'antd/lib/form';
import { ContributionArgs, CONTRIBUTION_STATUS } from 'src/types';
import Back from 'components/Back';
import store from 'src/store';
import './index.less';
type Props = FormComponentProps & RouteComponentProps<{ id?: string }>;
class ContributionForm extends React.Component<Props> {
constructor(props: Props) {
super(props);
const id = this.getId();
if (id) {
store.fetchContributionDetail(id);
}
}
render() {
const { getFieldDecorator } = this.props.form;
let defaults: Partial<ContributionArgs> = {
proposalId: '',
userId: '',
amount: '',
txId: '',
};
const id = this.getId();
const contribution = this.getContribution();
if (id) {
if (store.contributionDetailFetching) {
return <Spin />;
}
if (!contribution) {
return <Exception type="404" desc="This contribution does not exist" />;
}
defaults = {
proposalId: contribution.proposal.proposalId,
userId: contribution.user.userid,
amount: contribution.amount,
txId: contribution.txId || '',
};
}
return (
<Form className="ContributionForm" layout="vertical" onSubmit={this.handleSubmit}>
<Back to="/contributions" text="Contributions" />
<Form.Item label="Proposal ID">
{getFieldDecorator('proposalId', {
initialValue: defaults.proposalId,
rules: [
{ required: true, message: 'Proposal ID is required' },
],
})(
<Input
autoComplete="off"
name="proposalId"
placeholder="Must be an existing proposal id"
autoFocus
/>,
)}
</Form.Item>
<Form.Item label="User ID">
{getFieldDecorator('userId', {
initialValue: defaults.userId,
rules: [
{ required: true, message: 'User ID is required' },
],
})(
<Input
autoComplete="off"
name="userId"
placeholder="Must be an existing user id"
/>,
)}
</Form.Item>
<Form.Item label="Contribution amount">
{getFieldDecorator('amount', {
initialValue: defaults.amount,
rules: [
{ required: true, message: 'Must have an amount specified' },
],
})(
<Input
autoComplete="off"
name="amount"
placeholder="Amount in ZEC, no more than 4 decimals"
/>,
)}
</Form.Item>
<Form.Item
label="Transaction ID"
help={`
Providing a txid will set status to CONFIRMED, leaving
blank will set status to PENDING.
`}>
{getFieldDecorator('txId', {
initialValue: defaults.txId,
})(
<Input
autoComplete="off"
name="amount"
placeholder="e.g. 7ae7bc1759a2bb9aa40b34daa3..."
/>,
)}
</Form.Item>
<div className="ContributionForm-buttons">
<Button type="primary" htmlType="submit" size="large">
Submit
</Button>
<Button type="ghost" size="large">
Cancel
</Button>
</div>
</Form>
);
}
private getId = () => {
const id = this.props.match.params.id;
if (id) {
return parseInt(id, 10);
}
};
private getContribution = () => {
const id = this.getId();
if (id && store.contributionDetail && store.contributionDetail.id === id) {
return store.contributionDetail;
}
};
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
this.props.form.validateFieldsAndScroll(async (err: any, values: any) => {
if (err) return;
const id = this.getId();
const args = {
...values,
status: values.txId
? CONTRIBUTION_STATUS.CONFIRMED
: CONTRIBUTION_STATUS.PENDING,
};
let msg;
if (id) {
await store.editContribution(id, args);
msg = 'Successfully updated contribution';
} else {
await store.createContribution(args);
msg = 'Successfully created contribution';
}
if (store.contributionSaved) {
message.success(msg, 3);
this.props.history.replace('/contributions');
}
});
};
}
export default Form.create()(withRouter(view(ContributionForm)));

View File

@ -0,0 +1,31 @@
.ContributionItem {
& h2 {
font-size: 1.1rem;
line-height: 1.8rem;
margin-bottom: 0.2rem;
> small {
opacity: 0.5;
}
& .ant-tag {
vertical-align: text-top;
margin-top: -0.1rem;
margin-left: 0.5rem;
}
}
& p {
font-size: 0.8rem;
color: rgba(#000, 0.5);
margin: 0;
> span {
margin-right: 0.5rem;
&:last-child {
margin: 0;
}
}
}
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import { List, Tag, Tooltip } from 'antd';
import { Link } from 'react-router-dom';
import { Contribution } from 'src/types';
import { CONTRIBUTION_STATUSES, getStatusById } from 'util/statuses';
import { formatDateSeconds } from 'util/time';
import './ContributionItem.less';
interface Props {
contribution: Contribution;
}
export default class ContributionItem extends React.PureComponent<Props> {
render() {
const { id, amount, dateCreated, proposal, user } = this.props.contribution;
const status = getStatusById(CONTRIBUTION_STATUSES, this.props.contribution.status);
return (
<List.Item
className="ContributionItem"
actions={[<Link key="edit" to={`/contributions/${id}/edit`}>edit</Link>]}
>
<Link to={`/contributions/${id}`}>
<h2>
{user.displayName} <small>for</small> {proposal.title}
<Tooltip title={status.hint}>
<Tag color={status.tagColor}>{status.tagDisplay}</Tag>
</Tooltip>
</h2>
<p>
<span><strong>Amount:</strong> {amount} ZEC</span>
<span><strong>Created:</strong> {formatDateSeconds(dateCreated)}</span>
</p>
</Link>
</List.Item>
);
}
}

View File

@ -0,0 +1,38 @@
import React from 'react';
import { view } from 'react-easy-state';
import { Button } from 'antd';
import { Link } from 'react-router-dom';
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';
class Contributions extends React.Component<{}> {
render() {
const { page } = store.contributions;
// NOTE: sync with /backend ... pagination.py ContributionPagination.SORT_MAP
const sorts = ['CREATED:DESC', 'CREATED:ASC', 'AMOUNT:DESC', 'AMOUNT:ASC'];
return (
<Pageable
page={page}
statuses={PROPOSAL_STATUSES}
sorts={sorts}
searchPlaceholder="Search amount or txid"
controlsExtra={
<Link to="/contributions/new">
<Button icon="plus">Create a contribution</Button>
</Link>
}
renderItem={(c: Contribution) =>
<ContributionItem key={c.id} contribution={c} />
}
handleSearch={store.fetchContributions}
handleChangeQuery={store.setContributionPageQuery}
handleResetQuery={store.resetContributionPageQuery}
/>
);
}
}
export default view(Contributions);

View File

@ -1,4 +1,4 @@
.Proposals {
.Pageable {
&-controls {
margin-bottom: 0.5rem;
@ -10,6 +10,10 @@
display: inline-block;
max-width: 300px;
}
&-extra {
float: right;
}
}
&-filters {

View File

@ -0,0 +1,180 @@
import React from 'react';
import qs from 'query-string';
import { uniq, without } from 'lodash';
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 './index.less';
interface OwnProps<T> {
page: PageData<T>;
statuses: Array<StatusSoT<any>>;
sorts: string[];
searchPlaceholder?: string;
controlsExtra?: React.ReactNode;
renderItem(item: T): React.ReactNode;
handleSearch(): void;
handleChangeQuery(query: Partial<PageQuery>): void;
handleResetQuery(): void;
}
type Props<T> = OwnProps<T> & RouteComponentProps<any>;
class Pageable<T> extends React.Component<Props<T>, {}> {
componentDidMount() {
this.setQueryFromUrl();
this.props.handleSearch();
}
render() {
const { page, statuses, 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>
))}
</Menu>
);
const sortMenu = (
<Menu onClick={this.handleSortClick}>
{sorts.map(s => (
<Menu.Item key={s}>{s}</Menu.Item>
))}
</Menu>
);
return (
<div className="Pageable">
<div className="Pageable-controls">
<Input.Search
className="Pageable-controls-search"
placeholder={searchPlaceholder}
onSearch={this.handleSearch}
/>
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
<Dropdown overlay={sortMenu} trigger={['click']}>
<Button>
{'Sort ' + page.sort} <Icon type="down" />
</Button>
</Dropdown>
<Button title="refresh" icon="reload" onClick={this.props.handleSearch} />
{controlsExtra && (
<div className="Pageable-controls-extra">
{controlsExtra}
</div>
)}
</div>
{page.search && (
<div>
Search: <b>{page.search}</b>
</div>
)}
{!!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 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
)}
<List
className="Pageable-list"
bordered
dataSource={page.items}
loading={loading}
renderItem={renderItem}
/>
<div className="Pageable-pagination">
<Pagination
current={page.page}
total={page.total}
pageSize={page.pageSize}
onChange={this.handlePageChange}
hideOnSinglePage={true}
/>
</div>
</div>
);
}
private setQueryFromUrl = () => {
const { history, statuses, match, handleResetQuery, handleChangeQuery } = this.props;
const parsed = qs.parse(history.location.search);
// 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}`] });
}
history.replace(match.url); // remove qs
}
};
private handleSortClick = (e: ClickParam) => {
this.props.handleChangeQuery({ sort: e.key });
this.props.handleSearch();
};
private handleFilterClick = (e: ClickParam) => {
const { page, handleChangeQuery, handleSearch } = this.props;
handleChangeQuery({
filters: uniq([`STATUS_${e.key}`, ...page.filters])
});
handleSearch();
};
private handleFilterClose = (filter: string) => {
const { page, handleChangeQuery, handleSearch } = this.props;
handleChangeQuery({ filters: without(page.filters, `STATUS_${filter}`) });
handleSearch();
};
private handleFilterClear = () => {
this.props.handleChangeQuery({ filters: [] });
this.props.handleSearch();
};
private handleSearch = (s: string) => {
this.props.handleChangeQuery({ search: s });
this.props.handleSearch();
};
private handlePageChange = (p: number) => {
this.props.handleChangeQuery({ page: p });
this.props.handleSearch();
};
}
export default withRouter(Pageable);

View File

@ -1,161 +1,29 @@
import React from 'react';
import qs from 'query-string';
import { view } from 'react-easy-state';
import { Icon, Button, Dropdown, Menu, Tag, List, Input, Pagination } from 'antd';
import { ClickParam } from 'antd/lib/menu';
import { RouteComponentProps, withRouter } from 'react-router';
import store from 'src/store';
import ProposalItem from './ProposalItem';
import { PROPOSAL_STATUS, Proposal } from 'src/types';
import { PROPOSAL_STATUSES, getStatusById } from 'util/statuses';
import './index.less';
type Props = RouteComponentProps<any>;
class ProposalsNaked extends React.Component<Props, {}> {
componentDidMount() {
this.setStoreFromQueryString();
store.fetchProposals();
}
import Pageable from 'components/Pageable';
import { Proposal } from 'src/types';
import { PROPOSAL_STATUSES } from 'util/statuses';
class Proposals extends React.Component<{}> {
render() {
const { page } = store.proposals;
const loading = !page.fetched || page.fetching;
const filters = page.filters
.filter(f => f.startsWith('STATUS_'))
.map(f => f.replace('STATUS_', '') as PROPOSAL_STATUS);
const statusFilterMenu = (
<Menu onClick={this.handleFilterClick}>
{PROPOSAL_STATUSES.map(f => (
<Menu.Item key={f.id}>{f.filterDisplay}</Menu.Item>
))}
</Menu>
);
const sortMenu = (
<Menu onClick={this.handleSortClick}>
{/* NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP */}
{['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'].map(s => (
<Menu.Item key={s}>{s}</Menu.Item>
))}
</Menu>
);
// NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP
const sorts = ['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'];
return (
<div className="Proposals">
<div className="Proposals-controls">
<Input.Search
className="Proposals-controls-search"
placeholder="search titles"
onSearch={this.handleSearch}
/>
<Dropdown overlay={statusFilterMenu} trigger={['click']}>
<Button>
Filter <Icon type="down" />
</Button>
</Dropdown>
<Dropdown overlay={sortMenu} trigger={['click']}>
<Button>
{'Sort ' + store.proposals.page.sort} <Icon type="down" />
</Button>
</Dropdown>
<Button title="refresh" icon="reload" onClick={store.fetchProposals} />
</div>
{page.search && (
<div>
Search: <b>{page.search}</b>
</div>
)}
{!!page.filters.length && (
<div className="Proposals-filters">
Filters:{' '}
{filters.map(sf => (
<Tag
key={sf}
onClose={() => this.handleFilterClose(sf)}
color={getStatusById(PROPOSAL_STATUSES, sf).tagColor}
closable
>
status: {sf}
</Tag>
))}
{filters.length > 1 && (
<Tag key="clear" onClick={this.handleFilterClear}>
clear
</Tag>
)}
</div>
)}
<List
className="Proposals-list"
bordered
dataSource={page.items}
loading={loading}
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
/>
<div className="Proposals-pagination">
<Pagination
current={page.page}
total={page.total}
pageSize={page.pageSize}
onChange={this.handlePageChange}
hideOnSinglePage={true}
/>
</div>
</div>
<Pageable
page={page}
statuses={PROPOSAL_STATUSES}
sorts={sorts}
searchPlaceholder="Search proposal titles"
renderItem={(p: Proposal) => <ProposalItem key={p.proposalId} {...p} />}
handleSearch={store.fetchProposals}
handleChangeQuery={store.setProposalPageQuery}
handleResetQuery={store.resetProposalPageQuery}
/>
);
}
private setStoreFromQueryString = () => {
const parsed = qs.parse(this.props.history.location.search);
// status filter
if (parsed.status) {
if (getStatusById(PROPOSAL_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
store.resetProposalPageQuery();
store.addProposalPageFilter('STATUS_' + parsed.status);
}
this.props.history.replace(this.props.match.url); // remove qs
}
};
private handleSortClick = (e: ClickParam) => {
store.proposals.page.sort = e.key;
store.fetchProposals();
};
private handleFilterClick = (e: ClickParam) => {
store.addProposalPageFilter('STATUS_' + e.key);
store.fetchProposals();
};
private handleFilterClose = (filter: PROPOSAL_STATUS) => {
store.removeProposalPageFilter('STATUS_' + filter);
store.fetchProposals();
};
private handleFilterClear = () => {
store.proposals.page.filters = [];
store.fetchProposals();
};
private handleSearch = (s: string) => {
store.proposals.page.search = s;
store.fetchProposals();
};
private handlePageChange = (p: number) => {
store.proposals.page.page = p;
store.fetchProposals();
};
}
const Proposals = withRouter(view(ProposalsNaked));
export default Proposals;
export default view(Proposals);

View File

@ -57,6 +57,12 @@ class Template extends React.Component<Props> {
<span className="nav-text">RFPs</span>
</Link>
</Menu.Item>
<Menu.Item key="contributions">
<Link to="/contributions">
<Icon type="dollar" />
<span className="nav-text">Contributions</span>
</Link>
</Menu.Item>
<Menu.Item key="emails">
<Link to="/emails">
<Icon type="mail" />

View File

@ -1,7 +1,17 @@
import { uniq, without, pick } from 'lodash';
import { pick } from 'lodash';
import { store } from 'react-easy-state';
import axios, { AxiosError } from 'axios';
import { User, Proposal, RFP, RFPArgs, EmailExample, PageQuery } from './types';
import {
User,
Proposal,
Contribution,
ContributionArgs,
RFP,
RFPArgs,
EmailExample,
PageQuery,
PageData,
} from './types';
// API
const api = axios.create({
@ -48,9 +58,7 @@ async function deleteUser(id: number) {
}
async function fetchProposals(params: Partial<PageQuery>) {
const { data } = await api.get('/admin/proposals', {
params,
});
const { data } = await api.get('/admin/proposals', { params });
return data;
}
@ -101,8 +109,29 @@ async function deleteRFP(id: number) {
await api.delete(`/admin/rfps/${id}`);
}
async function getContributions(params: PageQuery) {
const { data } = await api.get('/admin/contributions', { params });
return data;
}
async function getContribution(id: number) {
const { data } = await api.get(`/admin/contributions/${id}`);
return data;
}
async function createContribution(args: ContributionArgs) {
const { data } = await api.post('/admin/contributions', args);
return data;
}
async function editContribution(id: number, args: ContributionArgs) {
const { data } = await api.put(`/admin/contributions/${id}`, args);
return data;
}
// STORE
const app = store({
/*** DATA ***/
hasCheckedLogin: false,
isLoggedIn: false,
loginError: '',
@ -124,21 +153,11 @@ const app = store({
userDeleted: false,
proposals: {
page: {
page: 1,
search: '',
sort: 'CREATED:DESC',
filters: [] as string[],
pageSize: 0,
total: 0,
items: [] as Proposal[],
fetching: false,
fetched: false,
},
page: createDefaultPageData<Proposal>('CREATED:DESC'),
},
proposalDetailFetching: false,
proposalDetail: null as null | Proposal,
proposalDetailFetching: false,
proposalDetailApproving: false,
rfps: [] as RFP[],
@ -149,8 +168,19 @@ const app = store({
rfpDeleting: false,
rfpDeleted: false,
contributions: {
page: createDefaultPageData<Contribution>('CREATED:DESC'),
},
contributionDetail: null as null | Contribution,
contributionDetailFetching: false,
contributionSaving: false,
contributionSaved: false,
emailExamples: {} as { [type: string]: EmailExample },
/*** ACTIONS ***/
removeGeneralError(i: number) {
app.generalError.splice(i, 1);
},
@ -165,6 +195,8 @@ const app = store({
}
},
// Auth
async checkLogin() {
app.isLoggedIn = await checkLogin();
app.hasCheckedLogin = true;
@ -197,6 +229,8 @@ const app = store({
app.statsFetching = false;
},
// Users
async fetchUsers() {
app.usersFetching = true;
try {
@ -232,6 +266,8 @@ const app = store({
app.userDeleting = false;
},
// Proposals
async fetchProposals() {
app.proposals.page.fetching = true;
try {
@ -247,6 +283,13 @@ const app = store({
app.proposals.page.fetching = false;
},
setProposalPageQuery(query: Partial<PageQuery>) {
app.proposals.page = {
...app.proposals.page,
...query,
};
},
getProposalPageQuery() {
return pick(app.proposals.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
},
@ -258,16 +301,6 @@ const app = store({
app.proposals.page.filters = [];
},
addProposalPageFilter(f: string) {
const current = app.proposals.page.filters;
app.proposals.page.filters = uniq([f, ...current]);
},
removeProposalPageFilter(f: string) {
const current = app.proposals.page.filters;
app.proposals.page.filters = without(current, f);
},
async fetchProposalDetail(id: number) {
app.proposalDetailFetching = true;
try {
@ -322,6 +355,8 @@ const app = store({
app.proposalDetailApproving = false;
},
// Email
async getEmailExample(type: string) {
try {
const example = await getEmailExample(type);
@ -334,6 +369,8 @@ const app = store({
}
},
// RFPs
async fetchRFPs() {
app.rfpsFetching = true;
try {
@ -382,8 +419,78 @@ const app = store({
}
app.rfpDeleting = false;
},
// Contributions
async fetchContributions() {
app.contributions.page.fetching = true;
try {
const page = await getContributions(app.getContributionPageQuery());
app.contributions.page = {
...app.contributions.page,
...page,
fetched: true,
};
} catch (e) {
handleApiError(e);
}
app.contributions.page.fetching = false;
},
setContributionPageQuery(query: Partial<PageQuery>) {
app.contributions.page = {
...app.contributions.page,
...query,
};
},
getContributionPageQuery() {
return pick(app.contributions.page, ['page', 'search', 'filters', 'sort']) as PageQuery;
},
resetContributionPageQuery() {
app.contributions.page.page = 1;
app.contributions.page.search = '';
app.contributions.page.sort = 'CREATED:DESC';
app.contributions.page.filters = [];
},
async fetchContributionDetail(id: number) {
app.contributionDetailFetching = true;
try {
app.contributionDetail = await getContribution(id);
} catch (e) {
handleApiError(e);
}
app.contributionDetailFetching = false;
},
async editContribution(id: number, args: ContributionArgs) {
app.contributionSaving = true;
app.contributionSaved = false;
try {
await editContribution(id, args);
app.contributionSaved = true;
} catch (e) {
handleApiError(e);
}
app.contributionSaving = false;
},
async createContribution(args: ContributionArgs) {
app.contributionSaving = true;
app.contributionSaved = false;
try {
await createContribution(args);
app.contributionSaved = true;
} catch (e) {
handleApiError(e);
}
app.contributionSaving = false;
}
});
// Utils
function handleApiError(e: AxiosError) {
if (e.response && e.response.data!.message) {
app.generalError.push(e.response!.data.message);
@ -394,6 +501,21 @@ function handleApiError(e: AxiosError) {
}
}
function createDefaultPageData<T>(sort: string): PageData<T> {
return {
sort,
page: 1,
search: '',
filters: [] as string[],
pageSize: 0,
total: 0,
items: [] as T[],
fetching: false,
fetched: false,
}
}
// Attach to window for inspection
(window as any).appStore = app;
// check login status periodically

View File

@ -76,14 +76,32 @@ export interface Comment {
dateCreated: number;
content: string;
}
// NOTE: sync with backend/utils/enums.py
export enum CONTRIBUTION_STATUS {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
DELETED = 'DELETED',
}
export interface Contribution {
id: number;
status: string;
status: CONTRIBUTION_STATUS;
txId: null | string;
amount: string;
dateCreated: number;
user: User;
proposal: Proposal;
addresses: {
transparent: string;
sprout: string;
memo: string;
};
}
export interface ContributionArgs {
proposalId: string | number;
userId: string | number;
amount: string;
status: string;
txId?: string;
}
export interface User {
accountAddress: string;
@ -123,3 +141,11 @@ export interface PageQuery {
search: string;
sort: string;
}
export interface PageData<T> extends PageQuery {
pageSize: number;
total: number;
items: T[];
fetching: boolean;
fetched: boolean;
}

View File

@ -1,4 +1,4 @@
import { PROPOSAL_STATUS, RFP_STATUS } from 'src/types';
import { PROPOSAL_STATUS, RFP_STATUS, CONTRIBUTION_STATUS } from 'src/types';
export interface StatusSoT<E> {
id: E;
@ -86,6 +86,30 @@ 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);
if (!result) {

View File

@ -1,6 +1,7 @@
from flask import Blueprint, request
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.extensions import db
@ -9,12 +10,13 @@ from grant.proposal.models import (
ProposalContribution,
proposals_schema,
proposal_schema,
proposal_contribution_schema,
user_proposal_contributions_schema,
)
from grant.user.models import User, users_schema, 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.enums import ProposalStatus
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.utils import pagination
from sqlalchemy import func, or_
@ -279,3 +281,109 @@ def delete_rfp(rfp_id):
db.session.delete(rfp)
db.session.commit()
return None, 200
# Contributions
@blueprint.route('/contributions', 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_contributions(page, filters, search, sort):
filters_workaround = request.args.getlist('filters[]')
page = pagination.contribution(
page=page,
filters=filters_workaround,
search=search,
sort=sort,
)
return page
@blueprint.route('/contributions', methods=['POST'])
@endpoint.api(
parameter('proposalId', type=int, required=True),
parameter('userId', type=int, required=True),
parameter('status', type=str, required=True),
parameter('amount', type=str, required=True),
parameter('txId', type=str, required=False),
)
@admin_auth_required
def create_contribution(proposal_id, user_id, status, amount, tx_id):
# Some fields set manually since we're admin, and normally don't do this
contribution = ProposalContribution(
proposal_id=proposal_id,
user_id=user_id,
amount=amount,
)
contribution.status = status
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200
@blueprint.route('/contributions/<contribution_id>', methods=['GET'])
@endpoint.api()
@admin_auth_required
def get_contribution(contribution_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
if not contribution:
return {"message": "No contribution matching that id"}, 404
return proposal_contribution_schema.dump(contribution), 200
@blueprint.route('/contributions/<contribution_id>', methods=['PUT'])
@endpoint.api(
parameter('proposalId', type=int, required=False),
parameter('userId', type=int, required=False),
parameter('status', type=str, required=False),
parameter('amount', type=str, required=False),
parameter('txId', type=str, required=False),
)
@admin_auth_required
def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id):
contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first()
if not contribution:
return {"message": "No contribution matching that id"}, 404
print((contribution_id, proposal_id, user_id, status, amount, tx_id))
# Proposal ID (must belong to an existing proposal)
if proposal_id:
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
return {"message": "No proposal matching that id"}, 400
contribution.proposal_id = proposal_id
# User ID (must belong to an existing user)
if user_id:
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "No user matching that id"}, 400
contribution.user_id = user_id
# Status (must be in list of statuses)
if status:
if not ContributionStatus.includes(status):
return {"message": "Invalid status"}, 400
contribution.status = status
# Amount (must be a Decimal parseable)
if amount:
try:
contribution.amount = str(Decimal(amount))
except:
return {"message": "Amount could not be parsed as number"}, 400
# 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

@ -132,6 +132,46 @@ class ProposalContribution(db.Model):
.order_by(ProposalContribution.date_created.desc()) \
.all()
@staticmethod
def validate(contribution):
proposal_id = contribution.get('proposal_id')
user_id = contribution.get('user_id')
status = contribution.get('status')
amount = contribution.get('amount')
tx_id = contribution.get('tx_id')
# Proposal ID (must belong to an existing proposal)
if proposal_id:
proposal = Proposal.query.filter(Proposal.id == proposal_id).first()
if not proposal:
raise ValidationException('No proposal matching that ID')
contribution.proposal_id = proposal_id
else:
raise ValidationException('Proposal ID is required')
# User ID (must belong to an existing user)
if user_id:
user = User.query.filter(User.id == user_id).first()
if not user:
raise ValidationException('No user matching that ID')
contribution.user_id = user_id
else:
raise ValidationException('User ID is required')
# Status (must be in list of statuses)
if status:
if not ContributionStatus.includes(status):
raise ValidationException('Invalid status')
contribution.status = status
else:
raise ValidationException('Status is required')
# Amount (must be a Decimal parseable)
if amount:
try:
contribution.amount = str(Decimal(amount))
except:
raise ValidationException('Amount must be a number')
else:
raise ValidationException('Amount is required')
def confirm(self, tx_id: str, amount: str):
self.status = ContributionStatus.CONFIRMED
self.tx_id = tx_id

View File

@ -1,8 +1,8 @@
import abc
from sqlalchemy import or_, and_
from grant.proposal.models import db, ma, Proposal
from .enums import ProposalStatus, ProposalStage, Category
from grant.proposal.models import db, ma, Proposal, ProposalContribution, proposal_contributions_schema
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus
def extract_filters(sw, strings):
@ -106,7 +106,63 @@ class ProposalPagination(Pagination):
}
class ContributionPagination(Pagination):
def __init__(self):
self.FILTERS = [f'STATUS_{s}' for s in ContributionStatus.list()]
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': ProposalContribution.date_created.desc(),
'CREATED:ASC': ProposalContribution.date_created,
'AMOUNT:DESC': ProposalContribution.amount.desc(),
'AMOUNT:ASC': ProposalContribution.amount,
}
def paginate(
self,
schema: ma.Schema=proposal_contributions_schema,
query: db.Query=None,
page: int=1,
filters: list=None,
search: str=None,
sort: str='PUBLISHED:DESC',
):
query = query or ProposalContribution.query
sort = sort or 'CREATED:DESC'
# FILTER
if filters:
self.validate_filters(filters)
status_filters = extract_filters('STATUS_', filters)
if status_filters:
query = query.filter(ProposalContribution.status.in_(status_filters))
# SORT (see self.SORT_MAP)
if sort:
self.validate_sort(sort)
query = query.order_by(self.SORT_MAP[sort])
# SEARCH can match txids or amounts
if search:
query = query.filter(or_(
ProposalContribution.amount.ilike(f'%{search}%'),
ProposalContribution.tx_id.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
# user = UserPagination().paginate