Merge pull request #187 from grant-project/arbiter-management
Arbiter management
This commit is contained in:
commit
589702c394
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||||
import { view } from 'react-easy-state';
|
import { view } from 'react-easy-state';
|
||||||
import { Button, Modal, Input, Icon, List, Avatar, message } from 'antd';
|
import { Button, Modal, Input, Icon, List, Avatar, message } from 'antd';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { Proposal, User } from 'src/types';
|
import { Proposal, User, PROPOSAL_ARBITER_STATUS } from 'src/types';
|
||||||
import Search from 'antd/lib/input/Search';
|
import Search from 'antd/lib/input/Search';
|
||||||
import { ButtonProps } from 'antd/lib/button';
|
import { ButtonProps } from 'antd/lib/button';
|
||||||
import './index.less';
|
import './index.less';
|
||||||
|
@ -34,6 +34,13 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
const { showSearch, searching } = this.state;
|
const { showSearch, searching } = this.state;
|
||||||
const { results, search, error } = store.arbitersSearch;
|
const { results, search, error } = store.arbitersSearch;
|
||||||
const showEmpty = !results.length && !searching;
|
const showEmpty = !results.length && !searching;
|
||||||
|
|
||||||
|
const disp = {
|
||||||
|
[PROPOSAL_ARBITER_STATUS.MISSING]: 'Nominate arbiter',
|
||||||
|
[PROPOSAL_ARBITER_STATUS.NOMINATED]: 'Change nomination',
|
||||||
|
[PROPOSAL_ARBITER_STATUS.ACCEPTED]: 'Change arbiter',
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* CONTROL */}
|
{/* CONTROL */}
|
||||||
|
@ -45,14 +52,14 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
onClick={this.handleShowSearch}
|
onClick={this.handleShowSearch}
|
||||||
{...this.props.buttonProps}
|
{...this.props.buttonProps}
|
||||||
>
|
>
|
||||||
{arbiter ? 'Change arbiter' : 'Set arbiter'}
|
{disp[arbiter.status]}
|
||||||
</Button>
|
</Button>
|
||||||
{/* SEARCH MODAL */}
|
{/* SEARCH MODAL */}
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<Modal
|
<Modal
|
||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
<Icon type="crown" /> Select an arbiter
|
<Icon type="crown" /> Nominate an arbiter
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
visible={true}
|
visible={true}
|
||||||
|
@ -96,7 +103,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
key="select"
|
key="select"
|
||||||
onClick={() => this.handleSelect(u)}
|
onClick={() => this.handleSelect(u)}
|
||||||
>
|
>
|
||||||
Select
|
Nominate
|
||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
@ -143,7 +150,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
|
||||||
await store.setArbiter(this.props.proposalId, user.userid);
|
await store.setArbiter(this.props.proposalId, user.userid);
|
||||||
message.success(
|
message.success(
|
||||||
<>
|
<>
|
||||||
Arbiter set for <b>{this.props.title}</b>
|
Arbiter nominated for <b>{this.props.title}</b>
|
||||||
</>,
|
</>,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -30,7 +30,7 @@ class Home extends React.Component {
|
||||||
<div>
|
<div>
|
||||||
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
|
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
|
||||||
live proposals <b>without an arbiter</b>.{' '}
|
live proposals <b>without an arbiter</b>.{' '}
|
||||||
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=OTHER_ARBITER">
|
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING">
|
||||||
Click here
|
Click here
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
to view them.
|
to view them.
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
import TextArea from 'antd/lib/input/TextArea';
|
import TextArea from 'antd/lib/input/TextArea';
|
||||||
import store from 'src/store';
|
import store from 'src/store';
|
||||||
import { formatDateSeconds } from 'util/time';
|
import { formatDateSeconds } from 'util/time';
|
||||||
import { PROPOSAL_STATUS } from 'src/types';
|
import { PROPOSAL_STATUS, PROPOSAL_ARBITER_STATUS } from 'src/types';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import Back from 'components/Back';
|
import Back from 'components/Back';
|
||||||
import Info from 'components/Info';
|
import Info from 'components/Info';
|
||||||
|
@ -68,6 +68,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
type: 'default',
|
type: 'default',
|
||||||
className: 'ProposalDetail-controls-control',
|
className: 'ProposalDetail-controls-control',
|
||||||
block: true,
|
block: true,
|
||||||
|
disabled: p.status !== PROPOSAL_STATUS.LIVE
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -209,13 +210,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderSetArbiter = () =>
|
const renderNominateArbiter = () =>
|
||||||
!p.arbiter &&
|
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
|
||||||
p.status === PROPOSAL_STATUS.LIVE && (
|
p.status === PROPOSAL_STATUS.LIVE && (
|
||||||
<Alert
|
<Alert
|
||||||
showIcon
|
showIcon
|
||||||
type="warning"
|
type="warning"
|
||||||
message="No Arbiter on Live Proposal"
|
message="No arbiter on live proposal"
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<p>An arbiter is required to review milestone payout requests.</p>
|
<p>An arbiter is required to review milestone payout requests.</p>
|
||||||
|
@ -225,6 +226,25 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderNominatedArbiter = () =>
|
||||||
|
PROPOSAL_ARBITER_STATUS.NOMINATED === p.arbiter.status &&
|
||||||
|
p.status === PROPOSAL_STATUS.LIVE && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type="info"
|
||||||
|
message="Arbiter has been nominated"
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
<b>{p.arbiter.user!.displayName}</b> has been nominated for arbiter of
|
||||||
|
this proposal but has not yet accepted.
|
||||||
|
</p>
|
||||||
|
<ArbiterControl {...p} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const renderDeetItem = (name: string, val: any) => (
|
const renderDeetItem = (name: string, val: any) => (
|
||||||
<div className="ProposalDetail-deet">
|
<div className="ProposalDetail-deet">
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
|
@ -242,7 +262,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderApproved()}
|
{renderApproved()}
|
||||||
{renderReview()}
|
{renderReview()}
|
||||||
{renderRejected()}
|
{renderRejected()}
|
||||||
{renderSetArbiter()}
|
{renderNominateArbiter()}
|
||||||
|
{renderNominatedArbiter()}
|
||||||
<Collapse defaultActiveKey={['brief', 'content']}>
|
<Collapse defaultActiveKey={['brief', 'content']}>
|
||||||
<Collapse.Panel key="brief" header="brief">
|
<Collapse.Panel key="brief" header="brief">
|
||||||
{p.brief}
|
{p.brief}
|
||||||
|
@ -279,10 +300,17 @@ class ProposalDetailNaked extends React.Component<Props, State> {
|
||||||
{renderDeetItem('contributed', p.contributed)}
|
{renderDeetItem('contributed', p.contributed)}
|
||||||
{renderDeetItem('funded (inc. matching)', p.funded)}
|
{renderDeetItem('funded (inc. matching)', p.funded)}
|
||||||
{renderDeetItem('matching', p.contributionMatching)}
|
{renderDeetItem('matching', p.contributionMatching)}
|
||||||
{p.arbiter &&
|
|
||||||
renderDeetItem(
|
{renderDeetItem(
|
||||||
'arbiter',
|
'arbiter',
|
||||||
<Link to={`/users/${p.arbiter.userid}`}>{p.arbiter.displayName}</Link>,
|
<>
|
||||||
|
{p.arbiter.user && (
|
||||||
|
<Link to={`/users/${p.arbiter.user.userid}`}>
|
||||||
|
{p.arbiter.user.displayName}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
({p.arbiter.status})
|
||||||
|
</>,
|
||||||
)}
|
)}
|
||||||
{p.rfp &&
|
{p.rfp &&
|
||||||
renderDeetItem(
|
renderDeetItem(
|
||||||
|
|
|
@ -36,6 +36,17 @@ export interface RFPArgs {
|
||||||
category: string;
|
category: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
// NOTE: sync with backend/grant/utils/enums.py ProposalArbiterStatus
|
||||||
|
export enum PROPOSAL_ARBITER_STATUS {
|
||||||
|
MISSING = 'MISSING',
|
||||||
|
NOMINATED = 'NOMINATED',
|
||||||
|
ACCEPTED = 'ACCEPTED',
|
||||||
|
}
|
||||||
|
export interface ProposalArbiter {
|
||||||
|
user?: User;
|
||||||
|
proposal: Proposal;
|
||||||
|
status: PROPOSAL_ARBITER_STATUS;
|
||||||
|
}
|
||||||
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
|
// NOTE: sync with backend/grant/utils/enums.py ProposalStatus
|
||||||
export enum PROPOSAL_STATUS {
|
export enum PROPOSAL_STATUS {
|
||||||
DRAFT = 'DRAFT',
|
DRAFT = 'DRAFT',
|
||||||
|
@ -68,7 +79,7 @@ export interface Proposal {
|
||||||
rejectReason: string;
|
rejectReason: string;
|
||||||
contributionMatching: number;
|
contributionMatching: number;
|
||||||
rfp?: RFP;
|
rfp?: RFP;
|
||||||
arbiter?: User;
|
arbiter: ProposalArbiter;
|
||||||
}
|
}
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
commentId: string;
|
commentId: string;
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import { PROPOSAL_STATUSES, RFP_STATUSES, CONTRIBUTION_STATUSES } from './statuses';
|
import {
|
||||||
|
PROPOSAL_STATUSES,
|
||||||
|
RFP_STATUSES,
|
||||||
|
CONTRIBUTION_STATUSES,
|
||||||
|
PROPOSAL_ARBITER_STATUSES,
|
||||||
|
} from './statuses';
|
||||||
|
|
||||||
export interface Filter {
|
export interface Filter {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -29,14 +34,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
|
||||||
group: 'Status',
|
group: 'Status',
|
||||||
}))
|
}))
|
||||||
// proposal has extra filters
|
// proposal has extra filters
|
||||||
.concat([
|
.concat(
|
||||||
{
|
PROPOSAL_ARBITER_STATUSES.map(s => ({
|
||||||
id: `OTHER_ARBITER`,
|
id: `ARBITER_${s.id}`,
|
||||||
display: `Other: Arbiter`,
|
display: `Arbiter: ${s.tagDisplay}`,
|
||||||
color: '#cf00d5',
|
color: s.tagColor,
|
||||||
group: 'Other',
|
group: 'Arbiter',
|
||||||
},
|
})),
|
||||||
]);
|
);
|
||||||
|
|
||||||
export const proposalFilters: Filters = {
|
export const proposalFilters: Filters = {
|
||||||
list: PROPOSAL_FILTERS,
|
list: PROPOSAL_FILTERS,
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
import { PROPOSAL_STATUS, RFP_STATUS, CONTRIBUTION_STATUS } from 'src/types';
|
import {
|
||||||
|
PROPOSAL_STATUS,
|
||||||
|
RFP_STATUS,
|
||||||
|
CONTRIBUTION_STATUS,
|
||||||
|
PROPOSAL_ARBITER_STATUS,
|
||||||
|
} from 'src/types';
|
||||||
|
|
||||||
export interface StatusSoT<E> {
|
export interface StatusSoT<E> {
|
||||||
id: E;
|
id: E;
|
||||||
|
@ -53,6 +58,27 @@ export const PROPOSAL_STATUSES: Array<StatusSoT<PROPOSAL_STATUS>> = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const PROPOSAL_ARBITER_STATUSES: Array<StatusSoT<PROPOSAL_ARBITER_STATUS>> = [
|
||||||
|
{
|
||||||
|
id: PROPOSAL_ARBITER_STATUS.MISSING,
|
||||||
|
tagDisplay: 'Missing',
|
||||||
|
tagColor: '#cf00d5',
|
||||||
|
hint: 'Proposal does not have an arbiter.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: PROPOSAL_ARBITER_STATUS.NOMINATED,
|
||||||
|
tagDisplay: 'Nominated',
|
||||||
|
tagColor: '#cf00d5',
|
||||||
|
hint: 'An arbiter has been nominated for this proposal.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
|
tagDisplay: 'Accepted',
|
||||||
|
tagColor: '#cf00d5',
|
||||||
|
hint: 'Proposal has an arbiter.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
|
export const RFP_STATUSES: Array<StatusSoT<RFP_STATUS>> = [
|
||||||
{
|
{
|
||||||
id: RFP_STATUS.DRAFT,
|
id: RFP_STATUS.DRAFT,
|
||||||
|
|
|
@ -7,6 +7,7 @@ from grant.email.send import generate_email, send_email
|
||||||
from grant.extensions import db
|
from grant.extensions import db
|
||||||
from grant.proposal.models import (
|
from grant.proposal.models import (
|
||||||
Proposal,
|
Proposal,
|
||||||
|
ProposalArbiter,
|
||||||
ProposalContribution,
|
ProposalContribution,
|
||||||
proposals_schema,
|
proposals_schema,
|
||||||
proposal_schema,
|
proposal_schema,
|
||||||
|
@ -17,7 +18,7 @@ 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.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.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
|
||||||
from grant.utils.misc import make_url
|
from grant.utils.misc import make_url
|
||||||
from grant.utils.enums import ProposalStatus, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ContributionStatus, ProposalArbiterStatus
|
||||||
from grant.utils import pagination
|
from grant.utils import pagination
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
|
|
||||||
|
@ -61,8 +62,9 @@ def stats():
|
||||||
.filter(Proposal.status == ProposalStatus.PENDING) \
|
.filter(Proposal.status == ProposalStatus.PENDING) \
|
||||||
.scalar()
|
.scalar()
|
||||||
proposal_no_arbiter_count = db.session.query(func.count(Proposal.id)) \
|
proposal_no_arbiter_count = db.session.query(func.count(Proposal.id)) \
|
||||||
|
.join(Proposal.arbiter) \
|
||||||
.filter(Proposal.status == ProposalStatus.LIVE) \
|
.filter(Proposal.status == ProposalStatus.LIVE) \
|
||||||
.filter(Proposal.arbiter_id == None) \
|
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
|
||||||
.scalar()
|
.scalar()
|
||||||
return {
|
return {
|
||||||
"userCount": user_count,
|
"userCount": user_count,
|
||||||
|
@ -156,15 +158,16 @@ def set_arbiter(proposal_id, user_id):
|
||||||
if not user:
|
if not user:
|
||||||
return {"message": "User not found"}, 404
|
return {"message": "User not found"}, 404
|
||||||
|
|
||||||
if proposal.arbiter_id != user.id:
|
|
||||||
# send email
|
# send email
|
||||||
|
code = user.email_verification.code
|
||||||
send_email(user.email_address, 'proposal_arbiter', {
|
send_email(user.email_address, 'proposal_arbiter', {
|
||||||
'proposal': proposal,
|
'proposal': proposal,
|
||||||
'proposal_url': make_url(f'/proposals/{proposal.id}'),
|
'proposal_url': make_url(f'/proposals/{proposal.id}'),
|
||||||
'arbitration_url': make_url(f'/profile/{user.id}?tab=arbitration'),
|
'accept_url': make_url(f'/email/arbiter?code={code}&proposalId={proposal.id}'),
|
||||||
})
|
})
|
||||||
proposal.arbiter_id = user.id
|
proposal.arbiter.user = user
|
||||||
db.session.add(proposal)
|
proposal.arbiter.status = ProposalArbiterStatus.NOMINATED
|
||||||
|
db.session.add(proposal.arbiter)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -156,9 +156,9 @@ def comment_reply(email_args):
|
||||||
|
|
||||||
def proposal_arbiter(email_args):
|
def proposal_arbiter(email_args):
|
||||||
return {
|
return {
|
||||||
'subject': f'You are now arbiter of {email_args["proposal"].title}',
|
'subject': f'You have been nominated for arbiter of {email_args["proposal"].title}',
|
||||||
'title': f'You are an Arbiter',
|
'title': f'Arbiter Nomination',
|
||||||
'preview': f'Congratulations, you have been promoted to arbiter of {email_args["proposal"].title}!',
|
'preview': f'Congratulations, you have been nominated for arbiter of {email_args["proposal"].title}!',
|
||||||
'subscription': EmailSubscription.ARBITER,
|
'subscription': EmailSubscription.ARBITER,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ from flask import Blueprint
|
||||||
from flask_yoloapi import endpoint
|
from flask_yoloapi import endpoint
|
||||||
|
|
||||||
from .models import EmailVerification, db
|
from .models import EmailVerification, db
|
||||||
|
from grant.utils.enums import ProposalArbiterStatus
|
||||||
|
|
||||||
blueprint = Blueprint("email", __name__, url_prefix="/api/v1/email")
|
blueprint = Blueprint("email", __name__, url_prefix="/api/v1/email")
|
||||||
|
|
||||||
|
@ -14,7 +15,7 @@ def verify_email(code):
|
||||||
ev.has_verified = True
|
ev.has_verified = True
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {"message": "Email verified"}, 200
|
return {"message": "Email verified"}, 200
|
||||||
else:
|
|
||||||
return {"message": "Invalid email code"}, 400
|
return {"message": "Invalid email code"}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@ -26,5 +27,21 @@ def unsubscribe_email(code):
|
||||||
ev.user.settings.unsubscribe_emails()
|
ev.user.settings.unsubscribe_emails()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return {"message": "Unsubscribed from all emails"}, 200
|
return {"message": "Unsubscribed from all emails"}, 200
|
||||||
else:
|
|
||||||
|
return {"message": "Invalid email code"}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<code>/arbiter/<proposal_id>", methods=["POST"])
|
||||||
|
@endpoint.api()
|
||||||
|
def accept_arbiter(code, proposal_id):
|
||||||
|
ev = EmailVerification.query.filter_by(code=code).first()
|
||||||
|
if ev:
|
||||||
|
# 1. check that the user has a nomination for this proposal
|
||||||
|
for ap in ev.user.arbiter_proposals:
|
||||||
|
if ap.proposal_id == int(proposal_id):
|
||||||
|
ap.accept_nomination(ev.user.id)
|
||||||
|
return {"message": "You are now the Arbiter"}, 200
|
||||||
|
|
||||||
|
return {"message": "No nomination for this code"}, 404
|
||||||
|
|
||||||
return {"message": "Invalid email code"}, 400
|
return {"message": "Invalid email code"}, 400
|
||||||
|
|
|
@ -10,7 +10,7 @@ from grant.extensions import ma, db
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
from grant.utils.misc import dt_to_unix, make_url
|
from grant.utils.misc import dt_to_unix, make_url
|
||||||
from grant.utils.requests import blockchain_get
|
from grant.utils.requests import blockchain_get
|
||||||
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus
|
from grant.utils.enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus
|
||||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||||
|
|
||||||
proposal_team = db.Table(
|
proposal_team = db.Table(
|
||||||
|
@ -150,13 +150,46 @@ class ProposalContribution(db.Model):
|
||||||
self.amount = amount
|
self.amount = amount
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalArbiter(db.Model):
|
||||||
|
__tablename__ = "proposal_arbiter"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
|
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
|
||||||
|
status = db.Column(db.String(255), nullable=False)
|
||||||
|
|
||||||
|
proposal = db.relationship("Proposal", lazy=True, back_populates="arbiter")
|
||||||
|
user = db.relationship("User", uselist=False, lazy=True, back_populates="arbiter_proposals")
|
||||||
|
|
||||||
|
def __init__(self, proposal_id: int, user_id: int = None, status: str = ProposalArbiterStatus.MISSING):
|
||||||
|
self.proposal_id = proposal_id
|
||||||
|
self.user_id = user_id
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def accept_nomination(self, user_id: int):
|
||||||
|
if self.user_id == user_id:
|
||||||
|
self.status = ProposalArbiterStatus.ACCEPTED
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
return
|
||||||
|
raise ValidationException('User not nominated for arbiter')
|
||||||
|
|
||||||
|
def reject_nomination(self, user_id: int):
|
||||||
|
if self.user_id == user_id:
|
||||||
|
self.status = ProposalArbiterStatus.MISSING
|
||||||
|
self.user = None
|
||||||
|
db.session.add(self)
|
||||||
|
db.session.commit()
|
||||||
|
return
|
||||||
|
raise ValidationException('User is not arbiter')
|
||||||
|
|
||||||
|
|
||||||
class Proposal(db.Model):
|
class Proposal(db.Model):
|
||||||
__tablename__ = "proposal"
|
__tablename__ = "proposal"
|
||||||
|
|
||||||
id = db.Column(db.Integer(), primary_key=True)
|
id = db.Column(db.Integer(), primary_key=True)
|
||||||
date_created = db.Column(db.DateTime)
|
date_created = db.Column(db.DateTime)
|
||||||
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
|
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
|
# Content info
|
||||||
status = db.Column(db.String(255), nullable=False)
|
status = db.Column(db.String(255), nullable=False)
|
||||||
|
@ -183,7 +216,7 @@ class Proposal(db.Model):
|
||||||
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
milestones = db.relationship("Milestone", 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")
|
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
|
||||||
arbiter = db.relationship("User", lazy=True, back_populates="arbitrated_proposals")
|
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -238,10 +271,19 @@ class Proposal(db.Model):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def create(**kwargs):
|
def create(**kwargs):
|
||||||
Proposal.validate(kwargs)
|
Proposal.validate(kwargs)
|
||||||
return Proposal(
|
proposal = Proposal(
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# arbiter needs proposal.id
|
||||||
|
db.session.add(proposal)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
arbiter = ProposalArbiter(proposal_id=proposal.id)
|
||||||
|
db.session.add(arbiter)
|
||||||
|
|
||||||
|
return proposal
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
|
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
|
||||||
status_filter = or_(Proposal.status == v for v in statuses)
|
status_filter = or_(Proposal.status == v for v in statuses)
|
||||||
|
@ -420,7 +462,7 @@ class ProposalSchema(ma.Schema):
|
||||||
milestones = ma.Nested("MilestoneSchema", many=True)
|
milestones = ma.Nested("MilestoneSchema", many=True)
|
||||||
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
|
||||||
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
|
||||||
arbiter = ma.Nested("UserSchema") # exclude=["arbitrated_proposals"])
|
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
|
||||||
|
|
||||||
def get_proposal_id(self, obj):
|
def get_proposal_id(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
@ -564,3 +606,21 @@ user_proposal_contribution_schema = ProposalContributionSchema(exclude=['user',
|
||||||
user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses'])
|
user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses'])
|
||||||
proposal_proposal_contribution_schema = ProposalContributionSchema(exclude=['proposal', 'addresses'])
|
proposal_proposal_contribution_schema = ProposalContributionSchema(exclude=['proposal', 'addresses'])
|
||||||
proposal_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['proposal', 'addresses'])
|
proposal_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['proposal', 'addresses'])
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalArbiterSchema(ma.Schema):
|
||||||
|
class Meta:
|
||||||
|
model = ProposalArbiter
|
||||||
|
fields = (
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"proposal",
|
||||||
|
"status"
|
||||||
|
)
|
||||||
|
|
||||||
|
user = ma.Nested("UserSchema") # , exclude=['arbiter_proposals'] (if UserSchema ever includes it)
|
||||||
|
proposal = ma.Nested("ProposalSchema", exclude=['arbiter'])
|
||||||
|
|
||||||
|
|
||||||
|
user_proposal_arbiter_schema = ProposalArbiterSchema(exclude=['user'])
|
||||||
|
user_proposal_arbiters_schema = ProposalArbiterSchema(many=True, exclude=['user'])
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
<p style="margin: 0 0 20px;">
|
<p style="margin: 0 0 20px;">
|
||||||
You have been made arbiter of
|
You have been nominated for arbiter of
|
||||||
<a href="{{ args.proposal_url }}" target="_blank">
|
<a href="{{ args.proposal_url }}" target="_blank">
|
||||||
{{ args.proposal.title }} </a
|
{{ args.proposal.title }} </a
|
||||||
>. You will be responsible for reviewing milestone payout requests.
|
>. You will be responsible for reviewing milestone payout requests should you
|
||||||
|
choose to accept.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||||
|
@ -16,13 +17,13 @@
|
||||||
bgcolor="{{ UI.PRIMARY }}"
|
bgcolor="{{ UI.PRIMARY }}"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="{{ args.arbitration_url }}"
|
href="{{ args.accept_url }}"
|
||||||
target="_blank"
|
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 {{
|
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
|
UI.PRIMARY
|
||||||
}}; display: inline-block;"
|
}}; display: inline-block;"
|
||||||
>
|
>
|
||||||
View your arbitrations
|
Accept Nomination
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
You have been made arbiter of {{ args.proposal.title }}. You will be responsible
|
You have been nominated for arbiter of {{ args.proposal.title }}. You will be responsible
|
||||||
for reviewing milestone payout requests.
|
for reviewing milestone payout requests.
|
||||||
|
|
||||||
View your arbitrations: {{ args.arbitration_url }}
|
Accept nomination by visiting: {{ args.accept_url }}
|
||||||
|
|
|
@ -118,7 +118,7 @@ class User(db.Model, UserMixin):
|
||||||
lazy=True, cascade="all, delete-orphan")
|
lazy=True, cascade="all, delete-orphan")
|
||||||
roles = db.relationship('Role', secondary='roles_users',
|
roles = db.relationship('Role', secondary='roles_users',
|
||||||
backref=db.backref('users', lazy='dynamic'))
|
backref=db.backref('users', lazy='dynamic'))
|
||||||
arbitrated_proposals = db.relationship("Proposal", lazy=True, back_populates="arbiter")
|
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
|
||||||
|
|
||||||
# TODO - add create and validate methods
|
# TODO - add create and validate methods
|
||||||
|
|
||||||
|
@ -240,14 +240,14 @@ class SelfUserSchema(ma.Schema):
|
||||||
"display_name",
|
"display_name",
|
||||||
"userid",
|
"userid",
|
||||||
"email_verified",
|
"email_verified",
|
||||||
"arbitrated_proposals"
|
"arbiter_proposals",
|
||||||
)
|
)
|
||||||
|
|
||||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||||
avatar = ma.Nested("AvatarSchema")
|
avatar = ma.Nested("AvatarSchema")
|
||||||
|
arbiter_proposals = ma.Nested("ProposalArbiterSchema", many=True, exclude=["user"])
|
||||||
userid = ma.Method("get_userid")
|
userid = ma.Method("get_userid")
|
||||||
email_verified = ma.Method("get_email_verified")
|
email_verified = ma.Method("get_email_verified")
|
||||||
arbitrated_proposals = ma.Nested("ProposalSchema", many=True, exclude=["arbiter"])
|
|
||||||
|
|
||||||
def get_userid(self, obj):
|
def get_userid(self, obj):
|
||||||
return obj.id
|
return obj.id
|
||||||
|
|
|
@ -11,6 +11,7 @@ from grant.proposal.models import (
|
||||||
ProposalContribution,
|
ProposalContribution,
|
||||||
user_proposal_contributions_schema,
|
user_proposal_contributions_schema,
|
||||||
user_proposals_schema,
|
user_proposals_schema,
|
||||||
|
user_proposal_arbiters_schema
|
||||||
)
|
)
|
||||||
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
|
from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user
|
||||||
from grant.utils.exceptions import ValidationException
|
from grant.utils.exceptions import ValidationException
|
||||||
|
@ -98,7 +99,8 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
||||||
pending_dump = user_proposals_schema.dump(pending)
|
pending_dump = user_proposals_schema.dump(pending)
|
||||||
result["pendingProposals"] = pending_dump
|
result["pendingProposals"] = pending_dump
|
||||||
if with_arbitrated and is_self:
|
if with_arbitrated and is_self:
|
||||||
result["arbitrated"] = user_proposals_schema.dump(user.arbitrated_proposals)
|
result["arbitrated"] = user_proposal_arbiters_schema.dump(user.arbiter_proposals)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
message = "User with id matching {} not found".format(user_id)
|
message = "User with id matching {} not found".format(user_id)
|
||||||
|
@ -372,3 +374,27 @@ def set_user_settings(user_id, email_subscriptions):
|
||||||
return {"message": str(e)}, 400
|
return {"message": str(e)}, 400
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return user_settings_schema.dump(g.current_user.settings)
|
return user_settings_schema.dump(g.current_user.settings)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/<user_id>/arbiter/<proposal_id>", methods=["PUT"])
|
||||||
|
@requires_same_user_auth
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('isAccept', type=bool)
|
||||||
|
)
|
||||||
|
def set_user_arbiter(user_id, proposal_id, is_accept):
|
||||||
|
try:
|
||||||
|
proposal = Proposal.query.filter_by(id=int(proposal_id)).first()
|
||||||
|
if not proposal:
|
||||||
|
return {"message": "No such proposal"}, 404
|
||||||
|
|
||||||
|
if is_accept:
|
||||||
|
proposal.arbiter.accept_nomination(g.current_user.id)
|
||||||
|
return {"message": "Accepted nomination"}, 200
|
||||||
|
else:
|
||||||
|
proposal.arbiter.reject_nomination(g.current_user.id)
|
||||||
|
return {"message": "Rejected nomination"}, 200
|
||||||
|
|
||||||
|
except ValidationException as e:
|
||||||
|
return {"message": str(e)}, 400
|
||||||
|
|
||||||
|
return user_settings_schema.dump(g.current_user.settings)
|
||||||
|
|
|
@ -68,3 +68,12 @@ class RFPStatusEnum(CustomEnum):
|
||||||
|
|
||||||
|
|
||||||
RFPStatus = RFPStatusEnum()
|
RFPStatus = RFPStatusEnum()
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalArbiterStatusEnum(CustomEnum):
|
||||||
|
MISSING = 'MISSING'
|
||||||
|
NOMINATED = 'NOMINATED'
|
||||||
|
ACCEPTED = 'ACCEPTED'
|
||||||
|
|
||||||
|
|
||||||
|
ProposalArbiterStatus = ProposalArbiterStatusEnum()
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import abc
|
import abc
|
||||||
from sqlalchemy import or_, and_
|
from sqlalchemy import or_, and_
|
||||||
|
|
||||||
from grant.proposal.models import db, ma, Proposal, ProposalContribution, proposal_contributions_schema
|
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
|
||||||
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus
|
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus
|
||||||
|
|
||||||
|
|
||||||
def extract_filters(sw, strings):
|
def extract_filters(sw, strings):
|
||||||
|
@ -49,7 +49,7 @@ class ProposalPagination(Pagination):
|
||||||
self.FILTERS = [f'STATUS_{s}' for s in ProposalStatus.list()]
|
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'STAGE_{s}' for s in ProposalStage.list()])
|
||||||
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
|
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
|
||||||
self.FILTERS.extend(['OTHER_ARBITER'])
|
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
|
||||||
self.PAGE_SIZE = 9
|
self.PAGE_SIZE = 9
|
||||||
self.SORT_MAP = {
|
self.SORT_MAP = {
|
||||||
'CREATED:DESC': Proposal.date_created.desc(),
|
'CREATED:DESC': Proposal.date_created.desc(),
|
||||||
|
@ -76,7 +76,7 @@ class ProposalPagination(Pagination):
|
||||||
status_filters = extract_filters('STATUS_', filters)
|
status_filters = extract_filters('STATUS_', filters)
|
||||||
stage_filters = extract_filters('STAGE_', filters)
|
stage_filters = extract_filters('STAGE_', filters)
|
||||||
cat_filters = extract_filters('CAT_', filters)
|
cat_filters = extract_filters('CAT_', filters)
|
||||||
other_filters = extract_filters('OTHER_', filters)
|
arbiter_filters = extract_filters('ARBITER_', filters)
|
||||||
|
|
||||||
if status_filters:
|
if status_filters:
|
||||||
query = query.filter(Proposal.status.in_(status_filters))
|
query = query.filter(Proposal.status.in_(status_filters))
|
||||||
|
@ -86,8 +86,9 @@ class ProposalPagination(Pagination):
|
||||||
# query = query.filter(Proposal.stage.in_(stage_filters))
|
# query = query.filter(Proposal.stage.in_(stage_filters))
|
||||||
if cat_filters:
|
if cat_filters:
|
||||||
query = query.filter(Proposal.category.in_(cat_filters))
|
query = query.filter(Proposal.category.in_(cat_filters))
|
||||||
if other_filters:
|
if arbiter_filters:
|
||||||
query = query.filter(Proposal.arbiter_id == None)
|
query = query.join(Proposal.arbiter) \
|
||||||
|
.filter(ProposalArbiter.status.in_(arbiter_filters))
|
||||||
|
|
||||||
# SORT (see self.SORT_MAP)
|
# SORT (see self.SORT_MAP)
|
||||||
if sort:
|
if sort:
|
||||||
|
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 86d300cb6d69
|
||||||
|
Revises: 310dca400b81
|
||||||
|
Create Date: 2019-02-08 13:06:39.201691
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '86d300cb6d69'
|
||||||
|
down_revision = '310dca400b81'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('proposal_arbiter',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('proposal_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=255), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['proposal_id'], ['proposal.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.drop_constraint('proposal_arbiter_id_fkey', 'proposal', type_='foreignkey')
|
||||||
|
op.drop_column('proposal', 'arbiter_id')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('proposal', sa.Column('arbiter_id', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||||
|
op.create_foreign_key('proposal_arbiter_id_fkey', 'proposal', 'user', ['arbiter_id'], ['id'])
|
||||||
|
op.drop_table('proposal_arbiter')
|
||||||
|
# ### end Alembic commands ###
|
|
@ -1,5 +1,7 @@
|
||||||
from grant.utils.enums import ProposalStatus
|
from grant.utils.enums import ProposalStatus
|
||||||
from grant.utils.admin import generate_admin_password_hash
|
from grant.utils.admin import generate_admin_password_hash
|
||||||
|
from grant.user.models import admin_user_schema
|
||||||
|
from grant.proposal.models import proposal_schema
|
||||||
from mock import patch
|
from mock import patch
|
||||||
|
|
||||||
from ..config import BaseProposalCreatorConfig
|
from ..config import BaseProposalCreatorConfig
|
||||||
|
@ -126,3 +128,19 @@ class TestAdminAPI(BaseProposalCreatorConfig):
|
||||||
self.assert200(resp)
|
self.assert200(resp)
|
||||||
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
|
||||||
self.assertEqual(resp.json["rejectReason"], "Funnzies.")
|
self.assertEqual(resp.json["rejectReason"], "Funnzies.")
|
||||||
|
|
||||||
|
@patch('grant.email.send.send_email')
|
||||||
|
def test_nominate_arbiter(self, mock_send_email):
|
||||||
|
mock_send_email.return_value.ok = True
|
||||||
|
self.login_admin()
|
||||||
|
|
||||||
|
# nominate arbiter
|
||||||
|
resp = self.app.put(
|
||||||
|
"/api/v1/admin/arbiters",
|
||||||
|
data={
|
||||||
|
'proposalId': self.proposal.id,
|
||||||
|
'userId': self.user.id
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assert200(resp)
|
||||||
|
# TODO - more tests
|
||||||
|
|
|
@ -34,6 +34,7 @@ const VerifyEmail = loadable(() => import('pages/email-verify'), opts);
|
||||||
const Callback = loadable(() => import('pages/callback'), opts);
|
const Callback = loadable(() => import('pages/callback'), opts);
|
||||||
const RecoverEmail = loadable(() => import('pages/email-recover'), opts);
|
const RecoverEmail = loadable(() => import('pages/email-recover'), opts);
|
||||||
const UnsubscribeEmail = loadable(() => import('pages/email-unsubscribe'), opts);
|
const UnsubscribeEmail = loadable(() => import('pages/email-unsubscribe'), opts);
|
||||||
|
const ArbiterEmail = loadable(() => import('pages/email-arbiter'), opts);
|
||||||
const RFP = loadable(() => import('pages/rfp'), opts);
|
const RFP = loadable(() => import('pages/rfp'), opts);
|
||||||
const RFPs = loadable(() => import('pages/rfps'), opts);
|
const RFPs = loadable(() => import('pages/rfps'), opts);
|
||||||
|
|
||||||
|
@ -277,6 +278,17 @@ const routeConfigs: RouteConfig[] = [
|
||||||
title: 'Unsubscribe email',
|
title: 'Unsubscribe email',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Arbiter email
|
||||||
|
route: {
|
||||||
|
path: '/email/arbiter',
|
||||||
|
component: ArbiterEmail,
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
title: 'Unsubscribe email',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// oauth callbacks
|
// oauth callbacks
|
||||||
route: {
|
route: {
|
||||||
|
|
|
@ -133,6 +133,14 @@ export function updateUserSettings(
|
||||||
return axios.put(`/api/v1/users/${userId}/settings`, { emailSubscriptions });
|
return axios.put(`/api/v1/users/${userId}/settings`, { emailSubscriptions });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateUserArbiter(
|
||||||
|
userId: number,
|
||||||
|
proposalId: number,
|
||||||
|
isAccept: boolean,
|
||||||
|
): Promise<any> {
|
||||||
|
return axios.put(`/api/v1/users/${userId}/arbiter/${proposalId}`, { isAccept });
|
||||||
|
}
|
||||||
|
|
||||||
export function requestUserRecoveryEmail(email: string): Promise<any> {
|
export function requestUserRecoveryEmail(email: string): Promise<any> {
|
||||||
return axios.post(`/api/v1/users/recover`, { email });
|
return axios.post(`/api/v1/users/recover`, { email });
|
||||||
}
|
}
|
||||||
|
@ -149,6 +157,10 @@ export function unsubscribeEmail(code: string): Promise<any> {
|
||||||
return axios.post(`/api/v1/email/${code}/unsubscribe`);
|
return axios.post(`/api/v1/email/${code}/unsubscribe`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function arbiterEmail(code: string, proposalId: number): Promise<any> {
|
||||||
|
return axios.post(`/api/v1/email/${code}/arbiter/${proposalId}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function getSocialAuthUrl(service: SOCIAL_SERVICE): Promise<any> {
|
export function getSocialAuthUrl(service: SOCIAL_SERVICE): Promise<any> {
|
||||||
return axios.get(`/api/v1/users/social/${service}/authurl`);
|
return axios.get(`/api/v1/users/social/${service}/authurl`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,64 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { UserProposal } from 'types';
|
import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS } from 'types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { AppState } from 'store/reducers';
|
import { AppState } from 'store/reducers';
|
||||||
|
import { updateUserArbiter } from 'api/api';
|
||||||
|
import { usersActions } from 'modules/users';
|
||||||
|
import { Button, Popconfirm, message } from 'antd';
|
||||||
import './ProfileArbitrated.less';
|
import './ProfileArbitrated.less';
|
||||||
|
|
||||||
|
const PAS = PROPOSAL_ARBITER_STATUS;
|
||||||
|
|
||||||
interface OwnProps {
|
interface OwnProps {
|
||||||
proposal: UserProposal;
|
arbiter: UserProposalArbiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
user: AppState['auth']['user'];
|
user: AppState['auth']['user'];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = OwnProps & StateProps;
|
interface DispatchProps {
|
||||||
|
fetchUser: typeof usersActions['fetchUser'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & StateProps & DispatchProps;
|
||||||
|
|
||||||
class ProfileArbitrated extends React.Component<Props, {}> {
|
class ProfileArbitrated extends React.Component<Props, {}> {
|
||||||
render() {
|
render() {
|
||||||
const { title, proposalId } = this.props.proposal;
|
const { status } = this.props.arbiter;
|
||||||
|
const { title, proposalId } = this.props.arbiter.proposal;
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
[PAS.MISSING]: <>{/* nada */}</>,
|
||||||
|
[PAS.NOMINATED]: <>You have been nominated to be the arbiter for this proposal.</>,
|
||||||
|
[PAS.ACCEPTED]: (
|
||||||
|
<>
|
||||||
|
As arbiter of this proposal, you are responsible for reviewing milestone payout
|
||||||
|
requests. You may{' '}
|
||||||
|
<Popconfirm
|
||||||
|
title="Stop acting as arbiter?"
|
||||||
|
onConfirm={() => this.acceptArbiter(false)}
|
||||||
|
>
|
||||||
|
<a href="#">opt out</a>
|
||||||
|
</Popconfirm>{' '}
|
||||||
|
at any time.
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
[PAS.MISSING]: <>{/* nada */}</>,
|
||||||
|
[PAS.NOMINATED]: (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => this.acceptArbiter(true)} type="primary">
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => this.acceptArbiter(false)}>Reject</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}</>,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ProfileArbitrated">
|
<div className="ProfileArbitrated">
|
||||||
|
@ -25,19 +66,29 @@ class ProfileArbitrated extends React.Component<Props, {}> {
|
||||||
<Link to={`/proposals/${proposalId}`} className="ProfileArbitrated-title">
|
<Link to={`/proposals/${proposalId}`} className="ProfileArbitrated-title">
|
||||||
{title}
|
{title}
|
||||||
</Link>
|
</Link>
|
||||||
<div className={`ProfileArbitrated-info`}>
|
<div className={`ProfileArbitrated-info`}>{info[status]}</div>
|
||||||
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>
|
||||||
|
<div className="ProfileArbitrated-block is-actions">{actions[status]}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private acceptArbiter = async (isAccept: boolean) => {
|
||||||
|
const {
|
||||||
|
arbiter: { proposal },
|
||||||
|
user,
|
||||||
|
fetchUser,
|
||||||
|
} = this.props;
|
||||||
|
await updateUserArbiter(user!.userid, proposal.proposalId, isAccept);
|
||||||
|
message.success(isAccept ? 'Accepted arbiter position' : 'Rejected arbiter position');
|
||||||
|
// refetch all the user data (includes the arbiter proposals)
|
||||||
|
await fetchUser(String(user!.userid));
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect<StateProps, {}, OwnProps, AppState>(state => ({
|
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||||
|
state => ({
|
||||||
user: state.auth.user,
|
user: state.auth.user,
|
||||||
}))(ProfileArbitrated);
|
}),
|
||||||
|
{ fetchUser: usersActions.fetchUser },
|
||||||
|
)(ProfileArbitrated);
|
||||||
|
|
|
@ -24,9 +24,9 @@ import Loader from 'components/Loader';
|
||||||
import ExceptionPage from 'components/ExceptionPage';
|
import ExceptionPage from 'components/ExceptionPage';
|
||||||
import ContributionModal from 'components/ContributionModal';
|
import ContributionModal from 'components/ContributionModal';
|
||||||
import LinkableTabs from 'components/LinkableTabs';
|
import LinkableTabs from 'components/LinkableTabs';
|
||||||
import './style.less';
|
|
||||||
import { UserContribution } from 'types';
|
import { UserContribution } from 'types';
|
||||||
import ProfileArbitrated from './ProfileArbitrated';
|
import ProfileArbitrated from './ProfileArbitrated';
|
||||||
|
import './style.less';
|
||||||
|
|
||||||
interface StateProps {
|
interface StateProps {
|
||||||
usersMap: AppState['users']['map'];
|
usersMap: AppState['users']['map'];
|
||||||
|
@ -206,7 +206,7 @@ class Profile extends React.Component<Props, State> {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{arbitrated.map(arb => (
|
{arbitrated.map(arb => (
|
||||||
<ProfileArbitrated key={arb.proposalId} proposal={arb} />
|
<ProfileArbitrated key={arb.proposal.proposalId} arbiter={arb} />
|
||||||
))}
|
))}
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ProposalDraft, CreateMilestone, STATUS } from 'types';
|
import { ProposalDraft, CreateMilestone, STATUS, PROPOSAL_ARBITER_STATUS } from 'types';
|
||||||
import { User } from 'types';
|
import { User } from 'types';
|
||||||
import { getAmountError, isValidAddress } from 'utils/validators';
|
import { getAmountError, isValidAddress } from 'utils/validators';
|
||||||
import { MILESTONE_STATE, Proposal } from 'types';
|
import { MILESTONE_STATE, Proposal } from 'types';
|
||||||
|
@ -192,6 +192,9 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
|
||||||
stage: 'preview',
|
stage: 'preview',
|
||||||
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
category: draft.category || PROPOSAL_CATEGORY.DAPP,
|
||||||
isStaked: true,
|
isStaked: true,
|
||||||
|
arbiter: {
|
||||||
|
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
|
},
|
||||||
milestones: draft.milestones.map((m, idx) => ({
|
milestones: draft.milestones.map((m, idx) => ({
|
||||||
index: idx,
|
index: idx,
|
||||||
title: m.title,
|
title: m.title,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
UserComment,
|
UserComment,
|
||||||
UserContribution,
|
UserContribution,
|
||||||
TeamInviteWithProposal,
|
TeamInviteWithProposal,
|
||||||
|
UserProposalArbiter,
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import types from './types';
|
import types from './types';
|
||||||
|
|
||||||
|
@ -20,7 +21,7 @@ export interface UserState extends User {
|
||||||
isUpdating: boolean;
|
isUpdating: boolean;
|
||||||
updateError: string | null;
|
updateError: string | null;
|
||||||
pendingProposals: UserProposal[];
|
pendingProposals: UserProposal[];
|
||||||
arbitrated: UserProposal[];
|
arbitrated: UserProposalArbiter[];
|
||||||
proposals: UserProposal[];
|
proposals: UserProposal[];
|
||||||
contributions: UserContribution[];
|
contributions: UserContribution[];
|
||||||
comments: UserComment[];
|
comments: UserComment[];
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Button } from 'antd';
|
||||||
|
import qs from 'query-string';
|
||||||
|
import { withRouter, RouteComponentProps, Link } from 'react-router-dom';
|
||||||
|
import Result from 'ant-design-pro/lib/Result';
|
||||||
|
import { arbiterEmail } from 'api/api';
|
||||||
|
import Loader from 'components/Loader';
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
isAccepting: boolean;
|
||||||
|
hasAccepted: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ArbiterEmail extends React.Component<RouteComponentProps, State> {
|
||||||
|
state: State = {
|
||||||
|
isAccepting: false,
|
||||||
|
hasAccepted: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const args = qs.parse(this.props.location.search);
|
||||||
|
if (args.code && args.proposalId) {
|
||||||
|
this.setState({ isAccepting: true });
|
||||||
|
arbiterEmail(args.code, parseInt(args.proposalId, 10))
|
||||||
|
.then(() => {
|
||||||
|
this.setState({
|
||||||
|
hasAccepted: true,
|
||||||
|
isAccepting: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.setState({
|
||||||
|
error: err.message || err.toString(),
|
||||||
|
isAccepting: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
error: `
|
||||||
|
Missing code or proposalId parameter from email.
|
||||||
|
Make sure you copied the full link.
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { hasAccepted, error } = this.state;
|
||||||
|
const args = qs.parse(this.props.location.search);
|
||||||
|
|
||||||
|
const actions = (
|
||||||
|
<div>
|
||||||
|
<Link to="/profile?tab=arbitrations">
|
||||||
|
<Button size="large" type="primary">
|
||||||
|
View arbitrations
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/proposals/${args.proposalId}`}>
|
||||||
|
<Button size="large" style={{ marginLeft: '0.5rem' }}>
|
||||||
|
Browse proposals
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAccepted) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
type="success"
|
||||||
|
title="Arbiter nomination accepted"
|
||||||
|
description="You are now responsible for approving payouts for the proposal"
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
type="error"
|
||||||
|
title="Unable to accept arbiter nomination"
|
||||||
|
description={error}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <Loader size="large" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(ArbiterEmail);
|
|
@ -30,7 +30,10 @@ export function formatUserFromGet(user: UserState) {
|
||||||
user.pendingProposals = user.pendingProposals.map(bnUserProp);
|
user.pendingProposals = user.pendingProposals.map(bnUserProp);
|
||||||
}
|
}
|
||||||
if (user.arbitrated) {
|
if (user.arbitrated) {
|
||||||
user.arbitrated = user.arbitrated.map(bnUserProp);
|
user.arbitrated = user.arbitrated.map(a => {
|
||||||
|
a.proposal = bnUserProp(a.proposal);
|
||||||
|
return a;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
user.proposals = user.proposals.map(bnUserProp);
|
user.proposals = user.proposals.map(bnUserProp);
|
||||||
user.contributions = user.contributions.map(c => {
|
user.contributions = user.contributions.map(c => {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Proposal,
|
Proposal,
|
||||||
ProposalMilestone,
|
ProposalMilestone,
|
||||||
STATUS,
|
STATUS,
|
||||||
|
PROPOSAL_ARBITER_STATUS,
|
||||||
} from 'types';
|
} from 'types';
|
||||||
import { PROPOSAL_CATEGORY } from 'api/constants';
|
import { PROPOSAL_CATEGORY } from 'api/constants';
|
||||||
import BN from 'bn.js';
|
import BN from 'bn.js';
|
||||||
|
@ -160,6 +161,17 @@ export function generateProposal({
|
||||||
stage: 'FUNDING_REQUIRED',
|
stage: 'FUNDING_REQUIRED',
|
||||||
category: PROPOSAL_CATEGORY.COMMUNITY,
|
category: PROPOSAL_CATEGORY.COMMUNITY,
|
||||||
isStaked: true,
|
isStaked: true,
|
||||||
|
arbiter: {
|
||||||
|
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
|
||||||
|
user: {
|
||||||
|
userid: 999,
|
||||||
|
displayName: 'Test Arbiter',
|
||||||
|
title: '',
|
||||||
|
emailAddress: 'test@arbiter.com',
|
||||||
|
avatar: null,
|
||||||
|
socialMedias: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
team: [
|
team: [
|
||||||
{
|
{
|
||||||
userid: 123,
|
userid: 123,
|
||||||
|
|
|
@ -20,6 +20,15 @@ export interface Contributor {
|
||||||
milestoneNoVotes: boolean[];
|
milestoneNoVotes: boolean[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProposalArbiter {
|
||||||
|
user?: User; // only set if there is nomination/acceptance
|
||||||
|
proposal: Proposal;
|
||||||
|
status: PROPOSAL_ARBITER_STATUS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProposalProposalArbiter = Omit<ProposalArbiter, 'proposal'>;
|
||||||
|
export type UserProposalArbiter = Omit<ProposalArbiter, 'user'>;
|
||||||
|
|
||||||
export interface ProposalDraft {
|
export interface ProposalDraft {
|
||||||
proposalId: number;
|
proposalId: number;
|
||||||
dateCreated: number;
|
dateCreated: number;
|
||||||
|
@ -49,6 +58,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
|
||||||
milestones: ProposalMilestone[];
|
milestones: ProposalMilestone[];
|
||||||
datePublished: number | null;
|
datePublished: number | null;
|
||||||
dateApproved: number | null;
|
dateApproved: number | null;
|
||||||
|
arbiter: ProposalProposalArbiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamInviteWithProposal extends TeamInvite {
|
export interface TeamInviteWithProposal extends TeamInvite {
|
||||||
|
@ -96,3 +106,9 @@ export enum STATUS {
|
||||||
LIVE = 'LIVE',
|
LIVE = 'LIVE',
|
||||||
DELETED = 'DELETED',
|
DELETED = 'DELETED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PROPOSAL_ARBITER_STATUS {
|
||||||
|
MISSING = 'MISSING',
|
||||||
|
NOMINATED = 'NOMINATED',
|
||||||
|
ACCEPTED = 'ACCEPTED',
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue