Merge branch 'develop' into redux-alien-auth-events

This commit is contained in:
AMStrix 2019-02-16 08:50:37 -06:00 committed by GitHub
commit be259af0cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 203 additions and 56 deletions

View File

@ -46,7 +46,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
{/* CONTROL */}
<Button
className="ArbiterControl-control"
loading={store.proposalDetailApproving}
loading={store.arbiterSaving}
icon="crown"
type="primary"
onClick={this.handleShowSearch}
@ -146,15 +146,13 @@ class ArbiterControlNaked extends React.Component<Props, State> {
private handleSelect = async (user: User) => {
this.setState({ showSearch: false });
store.searchArbitersClear();
try {
await store.setArbiter(this.props.proposalId, user.userid);
if (store.arbiterSaved) {
message.success(
<>
Arbiter nominated for <b>{this.props.title}</b>
<b>{user.displayName}</b> nominated as arbiter of <b>{this.props.title}</b>
</>,
);
} catch (e) {
message.error(`Could not set arbiter: ${e}`);
}
};

View File

@ -17,8 +17,13 @@ import {
} from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
import { formatDateSeconds } from 'util/time';
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS, MILESTONE_STAGE } from 'src/types';
import { formatDateSeconds, formatDurationSeconds } from 'util/time';
import {
PROPOSAL_STATUS,
PROPOSAL_ARBITER_STATUS,
MILESTONE_STAGE,
PROPOSAL_STAGE,
} from 'src/types';
import { Link } from 'react-router-dom';
import Back from 'components/Back';
import Info from 'components/Info';
@ -52,6 +57,11 @@ class ProposalDetailNaked extends React.Component<Props, State> {
return 'loading proposal...';
}
const needsArbiter =
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
p.status === PROPOSAL_STATUS.LIVE &&
!p.isFailed;
const renderDeleteControl = () => (
<Popconfirm
onConfirm={this.handleDelete}
@ -72,7 +82,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
type: 'default',
className: 'ProposalDetail-controls-control',
block: true,
disabled: p.status !== PROPOSAL_STATUS.LIVE,
disabled: p.status !== PROPOSAL_STATUS.LIVE || p.isFailed,
}}
/>
);
@ -98,7 +108,14 @@ class ProposalDetailNaked extends React.Component<Props, State> {
okText="ok"
cancelText="cancel"
>
<Switch checked={p.contributionMatching === 1} loading={false} />{' '}
<Switch
checked={p.contributionMatching === 1}
loading={false}
disabled={
p.isFailed ||
[PROPOSAL_STAGE.WIP, PROPOSAL_STAGE.COMPLETED].includes(p.stage)
}
/>{' '}
</Popconfirm>
<span>
matching{' '}
@ -108,6 +125,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<span>
<b>Contribution matching</b>
<br /> Funded amount will be multiplied by 2.
<br /> <i>Disabled after proposal is fully-funded.</i>
</span>
}
/>
@ -215,8 +233,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
);
const renderNominateArbiter = () =>
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
p.status === PROPOSAL_STATUS.LIVE && (
needsArbiter && (
<Alert
showIcon
type="warning"
@ -297,6 +314,21 @@ class ProposalDetailNaked extends React.Component<Props, State> {
);
};
const renderFailed = () =>
p.isFailed && (
<Alert
showIcon
type="error"
message="Funding failed"
description={
<>
This proposal failed to reach its funding goal of <b>{p.target} ZEC</b> by{' '}
<b>{formatDateSeconds(p.datePublished + p.deadlineDuration)}</b>.
</>
}
/>
);
const renderDeetItem = (name: string, val: any) => (
<div className="ProposalDetail-deet">
<span>{name}</span>
@ -317,6 +349,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderNominateArbiter()}
{renderNominatedArbiter()}
{renderMilestoneAccepted()}
{renderFailed()}
<Collapse defaultActiveKey={['brief', 'content']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
@ -347,6 +380,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
<Card title="Details" size="small">
{renderDeetItem('id', p.proposalId)}
{renderDeetItem('created', formatDateSeconds(p.dateCreated))}
{renderDeetItem('published', formatDateSeconds(p.datePublished))}
{renderDeetItem(
'deadlineDuration',
formatDurationSeconds(p.deadlineDuration),
)}
{p.datePublished &&
renderDeetItem(
'(deadline)',
formatDateSeconds(p.datePublished + p.deadlineDuration),
)}
{renderDeetItem('isFailed', JSON.stringify(p.isFailed))}
{renderDeetItem('status', p.status)}
{renderDeetItem('stage', p.stage)}
{renderDeetItem('category', p.category)}

View File

@ -181,6 +181,9 @@ const app = store({
userDeleting: false,
userDeleted: false,
arbiterSaving: false,
arbiterSaved: false,
arbitersSearch: {
search: '',
results: [] as User[],
@ -385,10 +388,17 @@ const app = store({
},
async setArbiter(proposalId: number, userId: number) {
// let component handle errors for this one
app.arbiterSaving = true;
app.arbiterSaved = false;
try {
const { proposal, user } = await setArbiter(proposalId, userId);
this.updateProposalInStore(proposal);
this.updateUserInStore(user);
app.arbiterSaved = true;
} catch (e) {
handleApiError(e);
}
app.arbiterSaving = false;
},
// Proposals

View File

@ -79,6 +79,13 @@ export enum PROPOSAL_STATUS {
DELETED = 'DELETED',
STAKING = 'STAKING',
}
// NOTE: sync with backend/grant/utils/enums.py ProposalStage
export enum PROPOSAL_STAGE {
PREVIEW = 'PREVIEW',
FUNDING_REQUIRED = 'FUNDING_REQUIRED',
WIP = 'WIP',
COMPLETED = 'COMPLETED',
}
export interface Proposal {
proposalId: number;
brief: string;
@ -87,9 +94,11 @@ export interface Proposal {
dateCreated: number;
dateApproved: number;
datePublished: number;
deadlineDuration: number;
isFailed: boolean;
title: string;
content: string;
stage: string;
stage: PROPOSAL_STAGE;
category: string;
milestones: Milestone[];
currentMilestone?: Milestone;

View File

@ -6,6 +6,14 @@ export const formatDateSeconds = (s: number) => {
return moment(s * 1000).format(DATE_FMT_STRING);
};
export const formatDateSecondsFromNow = (s: number) => {
return moment(s * 1000).fromNow();
};
export const formatDateMs = (s: number) => {
return moment(s).format(DATE_FMT_STRING);
};
export const formatDurationSeconds = (s: number) => {
return moment.duration(s, 'seconds').humanize();
};

View File

@ -209,6 +209,13 @@ def set_arbiter(proposal_id, user_id):
if not proposal:
return {"message": "Proposal not found"}, 404
for member in proposal.team:
if member.id == user_id:
return {"message": "Cannot set proposal team member as arbiter"}, 400
if proposal.is_failed:
return {"message": "Cannot set arbiter on failed proposal"}, 400
user = User.query.filter(User.id == user_id).first()
if not user:
return {"message": "User not found"}, 404
@ -281,13 +288,7 @@ def update_proposal(id, contribution_matching):
proposal = Proposal.query.filter(Proposal.id == id).first()
if proposal:
if contribution_matching is not None:
# enforce 1 or 0 for now
if contribution_matching == 0.0 or contribution_matching == 1.0:
proposal.contribution_matching = contribution_matching
# TODO: trigger check if funding target reached OR make sure
# job schedule checks for funding completion include matching funds
else:
return {"message": f"Bad value for contributionMatching: {contribution_matching}"}, 400
proposal.set_contribution_matching(contribution_matching)
db.session.commit()
return proposal_schema.dump(proposal)
@ -499,6 +500,11 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id):
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.flush()
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200
@ -528,6 +534,10 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
if not contribution:
return {"message": "No contribution matching that id"}, 404
# do not allow editing contributions once a proposal has become funded
if contribution.proposal.is_funded:
return {"message": "Cannot edit contributions to fully-funded proposals"}, 400
print((contribution_id, proposal_id, user_id, status, amount, tx_id))
# Proposal ID (must belong to an existing proposal)
@ -558,5 +568,10 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_
contribution.tx_id = tx_id
db.session.add(contribution)
db.session.flush()
contribution.proposal.set_pending_when_ready()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return proposal_contribution_schema.dump(contribution), 200

View File

@ -82,6 +82,14 @@ class Milestone(db.Model):
self.reject_reason = reason
self.reject_arbiter_id = arbiter_id
def accept_immediate(self):
if self.immediate_payout and self.index == 0:
self.date_requested = datetime.datetime.now()
self.stage = MilestoneStage.ACCEPTED
self.date_accepted = datetime.datetime.now()
db.session.add(self)
db.session.flush()
def accept_request(self, arbiter_id: int):
if self.stage != MilestoneStage.REQUESTED:
raise MilestoneException(f'Cannot accept payout request for milestone at {self.stage} stage')

View File

@ -353,6 +353,7 @@ class Proposal(db.Model):
return contribution
# state: status (DRAFT || REJECTED) -> (PENDING || STAKING)
def submit_for_approval(self):
self.validate_publishable()
allowed_statuses = [ProposalStatus.DRAFT, ProposalStatus.REJECTED]
@ -365,6 +366,21 @@ class Proposal(db.Model):
else:
self.status = ProposalStatus.STAKING
def set_pending_when_ready(self):
if self.status == ProposalStatus.STAKING and self.is_staked:
self.set_pending()
# state: status STAKING -> PENDING
def set_pending(self):
if self.status != ProposalStatus.STAKING:
raise ValidationException(f"Proposal status must be staking in order to be set to pending")
if not self.is_staked:
raise ValidationException(f"Proposal is not fully staked, cannot set to pending")
self.status = ProposalStatus.PENDING
db.session.add(self)
db.session.flush()
# state: status PENDING -> (APPROVED || REJECTED)
def approve_pending(self, is_approve, reject_reason=None):
self.validate_publishable()
# specific validation
@ -394,16 +410,47 @@ class Proposal(db.Model):
'admin_note': reject_reason
})
# state: status APPROVE -> LIVE, stage PREVIEW -> FUNDING_REQUIRED
def publish(self):
self.validate_publishable()
# specific validation
if not self.status == ProposalStatus.APPROVED:
raise ValidationException(f"Proposal status must be approved")
self.date_published = datetime.datetime.now()
self.status = ProposalStatus.LIVE
self.stage = ProposalStage.FUNDING_REQUIRED
def set_funded_when_ready(self):
if self.status == ProposalStatus.LIVE and self.is_funded:
self.set_funded()
# state: stage FUNDING_REQUIRED -> WIP
def set_funded(self):
if self.status != ProposalStatus.LIVE:
raise ValidationException(f"Proposal status must be live in order transition to funded state")
if self.stage != ProposalStage.FUNDING_REQUIRED:
raise ValidationException(f"Proposal stage must be funding_required in order transition to funded state")
if not self.is_funded:
raise ValidationException(f"Proposal is not fully funded, cannot set to funded state")
self.stage = ProposalStage.WIP
db.session.add(self)
db.session.flush()
# check the first step, if immediate payout bump it to accepted
self.current_milestone.accept_immediate()
def set_contribution_matching(self, matching: float):
# do not allow on funded/WIP proposals
if self.is_funded:
raise ValidationException("Cannot set contribution matching on fully-funded proposal")
# enforce 1 or 0 for now
if matching == 0.0 or matching == 1.0:
self.contribution_matching = matching
db.session.add(self)
db.session.flush()
self.set_funded_when_ready()
else:
raise ValidationException("Bad value for contribution_matching, must be 1 or 0")
@hybrid_property
def contributed(self):
contributions = ProposalContribution.query \
@ -429,7 +476,15 @@ class Proposal(db.Model):
@hybrid_property
def is_funded(self):
return Decimal(self.contributed) >= Decimal(self.target)
return Decimal(self.funded) >= Decimal(self.target)
@hybrid_property
def is_failed(self):
if not self.status == ProposalStatus.LIVE or not self.date_published:
return False
deadline = self.date_published + datetime.timedelta(seconds=self.deadline_duration)
passed = deadline < datetime.datetime.now()
return passed and not self.is_funded
@hybrid_property
def current_milestone(self):
@ -458,6 +513,7 @@ class ProposalSchema(ma.Schema):
"target",
"contributed",
"is_staked",
"is_failed",
"funded",
"content",
"comments",

View File

@ -489,11 +489,7 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
db.session.flush()
if contribution.proposal.status == ProposalStatus.STAKING:
# fully staked, set status PENDING
if contribution.proposal.is_staked: # Decimal(contribution.proposal.contributed) >= PROPOSAL_STAKING_AMOUNT:
contribution.proposal.status = ProposalStatus.PENDING
db.session.add(contribution.proposal)
db.session.flush()
contribution.proposal.set_pending_when_ready()
# email progress of staking, partial or complete
send_email(contribution.user.email_address, 'staking_contribution_confirmed', {
@ -524,12 +520,9 @@ def post_contribution_confirmation(contribution_id, to, amount, txid):
})
# TODO: Once we have a task queuer in place, queue emails to everyone
# on funding target reached.
if contribution.proposal.status == ProposalStatus.LIVE:
if contribution.proposal.is_funded:
contribution.proposal.stage = ProposalStage.WIP
db.session.add(contribution.proposal)
db.session.flush()
contribution.proposal.set_funded_when_ready()
db.session.commit()
return None, 200

View File

@ -1,8 +1,8 @@
<p style="margin: 0 0 20px;">
The proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
>
payout of <b>{{ args.amount }} ZEC</b> has been approved.
</p>

View File

@ -1,7 +1,7 @@
<p style="margin: 0 0 20px;">
Hooray! <b>{{ args.amount }} ZEC</b> has been paid out for
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }} </a
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
>! You can view the transaction below:
</p>

View File

@ -1,8 +1,8 @@
<p style="margin: 0 0 20px;">
The payout request for proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
>
has been rejected.
</p>

View File

@ -1,8 +1,8 @@
<p style="margin: 0 0 20px;">
A payout request for the proposal milestone
<a href="{{ args.proposal_milestones_url }}" target="_blank">
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}
</a>
{{ args.proposal.title }} - {{ args.proposal.current_milestone.title }}</a
>
has been made. As arbiter, you are responsible for reviewing this request.
</p>

View File

@ -1,7 +1,7 @@
<p style="margin: 0 0 20px;">
You have been nominated for arbiter of
<a href="{{ args.proposal_url }}" target="_blank">
{{ args.proposal.title }} </a
{{ args.proposal.title }}</a
>. You will be responsible for reviewing milestone payout requests should you
choose to accept.
</p>

View File

@ -97,7 +97,8 @@ class TestAdminAPI(BaseProposalCreatorConfig):
def test_update_proposal_bad_matching(self):
self.login_admin()
resp = self.app.put(f"/api/v1/admin/proposals/{self.proposal.id}", data={"contributionMatching": 2})
self.assert400(resp)
self.assert500(resp)
self.assertIn('Bad value', resp.json['data'])
@patch('requests.get', side_effect=mock_blockchain_api_requests)
def test_approve_proposal(self, mock_get):
@ -140,7 +141,7 @@ class TestAdminAPI(BaseProposalCreatorConfig):
"/api/v1/admin/arbiters",
data={
'proposalId': self.proposal.id,
'userId': self.user.id
'userId': self.other_user.id
}
)
self.assert200(resp)

View File

@ -546,20 +546,25 @@ const MilestoneAction: React.SFC<MilestoneProps> = p => {
}
// special warning if no arbiter is set for team members
if (!p.hasArbiter && p.isTeamMember) {
if (!p.hasArbiter && p.isTeamMember && p.stage === MILESTONE_STAGE.IDLE) {
content = (
<Alert
type="error"
type="info"
message="Arbiter not assigned"
description={
<>
<p>
We are sorry for the inconvenience, but in order to have milestone payouts
reviewed an arbiter must be assigned. Please{' '}
Arbiters are users who review requests for payment. When they have approved
a payment the grant administrators are then notified to make payment.
</p>
<p>
It typically takes a couple of days to have an arbiter assigned. Please{' '}
<Link target="_blank" to="/contact">
contact support
</Link>{' '}
for help.
if you have waited longer than three days.
</p>
</>
}
/>
);