diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 17fc3634..3a670359 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -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 { + + + + )} diff --git a/admin/src/components/ContributionDetail/index.less b/admin/src/components/ContributionDetail/index.less new file mode 100644 index 00000000..43198302 --- /dev/null +++ b/admin/src/components/ContributionDetail/index.less @@ -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; + } + } +} diff --git a/admin/src/components/ContributionDetail/index.tsx b/admin/src/components/ContributionDetail/index.tsx new file mode 100644 index 00000000..9a6f04f2 --- /dev/null +++ b/admin/src/components/ContributionDetail/index.tsx @@ -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; + +class ContributionDetail extends React.Component { + 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) => ( +
+
+ {val} +
+
+ {label} +
+
+ ); + + return ( +
+ + + {/* MAIN */} + + + +
{JSON.stringify(c.addresses, null, 4)}
+
+ + + + + + + + + + +
{JSON.stringify(c, null, 4)}
+
+
+ + + {/* RIGHT SIDE */} + + {/* ACTIONS */} + + + + + + + {/* DETAILS */} + + {renderDeetItem('id', c.id)} + {renderDeetItem('created', formatDateSeconds(c.dateCreated))} + {renderDeetItem('status', c.status)} + {renderDeetItem('amount', c.amount)} + {renderDeetItem('txid', c.txId + ? + : N/A + )} + + +
+
+ ); + } + + private getIdFromQuery = () => { + return Number(this.props.match.params.id); + }; +} + +export default withRouter(view(ContributionDetail)); diff --git a/admin/src/components/ContributionForm/index.less b/admin/src/components/ContributionForm/index.less new file mode 100644 index 00000000..2f266f02 --- /dev/null +++ b/admin/src/components/ContributionForm/index.less @@ -0,0 +1,9 @@ +.ContributionForm { + &-buttons { + margin-top: 2rem; + + .ant-btn { + margin-right: 0.5rem; + } + } +} diff --git a/admin/src/components/ContributionForm/index.tsx b/admin/src/components/ContributionForm/index.tsx new file mode 100644 index 00000000..a12fd4a1 --- /dev/null +++ b/admin/src/components/ContributionForm/index.tsx @@ -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 { + constructor(props: Props) { + super(props); + const id = this.getId(); + if (id) { + store.fetchContributionDetail(id); + } + } + + render() { + const { getFieldDecorator } = this.props.form; + + let defaults: Partial = { + proposalId: '', + userId: '', + amount: '', + txId: '', + }; + const id = this.getId(); + const contribution = this.getContribution(); + if (id) { + if (store.contributionDetailFetching) { + return ; + } + if (!contribution) { + return ; + } + defaults = { + proposalId: contribution.proposal.proposalId, + userId: contribution.user.userid, + amount: contribution.amount, + txId: contribution.txId || '', + }; + } + + return ( +
+ + + {getFieldDecorator('proposalId', { + initialValue: defaults.proposalId, + rules: [ + { required: true, message: 'Proposal ID is required' }, + ], + })( + , + )} + + + + {getFieldDecorator('userId', { + initialValue: defaults.userId, + rules: [ + { required: true, message: 'User ID is required' }, + ], + })( + , + )} + + + + {getFieldDecorator('amount', { + initialValue: defaults.amount, + rules: [ + { required: true, message: 'Must have an amount specified' }, + ], + })( + , + )} + + + + {getFieldDecorator('txId', { + initialValue: defaults.txId, + })( + , + )} + + +
+ + +
+ + ); + } + + 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) => { + 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))); diff --git a/admin/src/components/Contributions/ContributionItem.less b/admin/src/components/Contributions/ContributionItem.less new file mode 100644 index 00000000..df743b55 --- /dev/null +++ b/admin/src/components/Contributions/ContributionItem.less @@ -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; + } + } + } +} diff --git a/admin/src/components/Contributions/ContributionItem.tsx b/admin/src/components/Contributions/ContributionItem.tsx new file mode 100644 index 00000000..3cbcbb8f --- /dev/null +++ b/admin/src/components/Contributions/ContributionItem.tsx @@ -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 { + render() { + const { id, amount, dateCreated, proposal, user } = this.props.contribution; + const status = getStatusById(CONTRIBUTION_STATUSES, this.props.contribution.status); + + return ( + edit]} + > + +

+ {user.displayName} for {proposal.title} + + {status.tagDisplay} + +

+

+ Amount: {amount} ZEC + Created: {formatDateSeconds(dateCreated)} +

+ +
+ ); + } +} \ No newline at end of file diff --git a/admin/src/components/Contributions/index.tsx b/admin/src/components/Contributions/index.tsx new file mode 100644 index 00000000..fe22d44e --- /dev/null +++ b/admin/src/components/Contributions/index.tsx @@ -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 ( + + + + } + renderItem={(c: Contribution) => + + } + handleSearch={store.fetchContributions} + handleChangeQuery={store.setContributionPageQuery} + handleResetQuery={store.resetContributionPageQuery} + /> + ); + } +} + +export default view(Contributions); diff --git a/admin/src/components/Proposals/index.less b/admin/src/components/Pageable/index.less similarity index 85% rename from admin/src/components/Proposals/index.less rename to admin/src/components/Pageable/index.less index b0c6d76e..d193ac53 100644 --- a/admin/src/components/Proposals/index.less +++ b/admin/src/components/Pageable/index.less @@ -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 { diff --git a/admin/src/components/Pageable/index.tsx b/admin/src/components/Pageable/index.tsx new file mode 100644 index 00000000..a36c6463 --- /dev/null +++ b/admin/src/components/Pageable/index.tsx @@ -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 { + page: PageData; + statuses: Array>; + sorts: string[]; + searchPlaceholder?: string; + controlsExtra?: React.ReactNode; + renderItem(item: T): React.ReactNode; + handleSearch(): void; + handleChangeQuery(query: Partial): void; + handleResetQuery(): void; +} + +type Props = OwnProps & RouteComponentProps; + +class Pageable extends React.Component, {}> { + 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 = ( + + {statuses.map(s => ( + {s.filterDisplay} + ))} + + ); + + const sortMenu = ( + + {sorts.map(s => ( + {s} + ))} + + ); + + return ( +
+
+ + + + + + + +
+ + {page.search && ( +
+ Search: {page.search} +
+ )} + + {!!page.filters.length && ( +
+ Filters:{' '} + {filters.map(sf => ( + this.handleFilterClose(sf)} + color={getStatusById(statuses, sf).tagColor} + closable + > + status: {sf} + + ))} + {filters.length > 1 && ( + + clear + + )} +
+ )} + + + +
+ +
+
+ ); + } + + 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); diff --git a/admin/src/components/Proposals/index.tsx b/admin/src/components/Proposals/index.tsx index ee65ed02..c98ef85a 100644 --- a/admin/src/components/Proposals/index.tsx +++ b/admin/src/components/Proposals/index.tsx @@ -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; - -class ProposalsNaked extends React.Component { - 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 = ( - - {PROPOSAL_STATUSES.map(f => ( - {f.filterDisplay} - ))} - - ); - - const sortMenu = ( - - {/* NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP */} - {['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC'].map(s => ( - {s} - ))} - - ); - + // NOTE: sync with /backend ... pagination.py ProposalPagination.SORT_MAP + const sorts = ['CREATED:DESC', 'CREATED:ASC', 'PUBLISHED:DESC', 'PUBLISHED:ASC']; return ( -
-
- - - - - - - -
- - {page.search && ( -
- Search: {page.search} -
- )} - - {!!page.filters.length && ( -
- Filters:{' '} - {filters.map(sf => ( - this.handleFilterClose(sf)} - color={getStatusById(PROPOSAL_STATUSES, sf).tagColor} - closable - > - status: {sf} - - ))} - {filters.length > 1 && ( - - clear - - )} -
- )} - - } - /> - -
- -
-
+ } + 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); diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index 86a07d8e..a1e8cb4d 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -57,6 +57,12 @@ class Template extends React.Component { RFPs + + + + Contributions + + diff --git a/admin/src/store.ts b/admin/src/store.ts index c7010616..ab20e4ef 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -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) { - 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('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('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) { + 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) { + 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(sort: string): PageData { + 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 diff --git a/admin/src/types.ts b/admin/src/types.ts index 8347feaa..6e40ca5b 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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 extends PageQuery { + pageSize: number; + total: number; + items: T[]; + fetching: boolean; + fetched: boolean; +} diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index 948dfbb2..903219f7 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -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 { id: E; @@ -86,6 +86,30 @@ export const RFP_STATUSES: Array> = [ }, ]; +export const CONTRIBUTION_STATUSES: Array> = [ + { + 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(statuses: Array>, id: E) { const result = statuses.find(s => s.id === id); if (!result) { diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 2a2ca055..24227a34 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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/', 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/', 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 + diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 3c1db9f5..c66c675b 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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 diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index aae87980..0e958706 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -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