Merge pull request #187 from grant-project/arbiter-management

Arbiter management
This commit is contained in:
Daniel Ternyak 2019-02-11 16:21:12 -06:00 committed by GitHub
commit 589702c394
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 540 additions and 86 deletions

View File

@ -3,7 +3,7 @@ import React from 'react';
import { view } from 'react-easy-state';
import { Button, Modal, Input, Icon, List, Avatar, message } from 'antd';
import store from 'src/store';
import { Proposal, User } from 'src/types';
import { Proposal, User, PROPOSAL_ARBITER_STATUS } from 'src/types';
import Search from 'antd/lib/input/Search';
import { ButtonProps } from 'antd/lib/button';
import './index.less';
@ -34,6 +34,13 @@ class ArbiterControlNaked extends React.Component<Props, State> {
const { showSearch, searching } = this.state;
const { results, search, error } = store.arbitersSearch;
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 (
<>
{/* CONTROL */}
@ -45,14 +52,14 @@ class ArbiterControlNaked extends React.Component<Props, State> {
onClick={this.handleShowSearch}
{...this.props.buttonProps}
>
{arbiter ? 'Change arbiter' : 'Set arbiter'}
{disp[arbiter.status]}
</Button>
{/* SEARCH MODAL */}
{showSearch && (
<Modal
title={
<>
<Icon type="crown" /> Select an arbiter
<Icon type="crown" /> Nominate an arbiter
</>
}
visible={true}
@ -96,7 +103,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
key="select"
onClick={() => this.handleSelect(u)}
>
Select
Nominate
</Button>,
]}
>
@ -143,7 +150,7 @@ class ArbiterControlNaked extends React.Component<Props, State> {
await store.setArbiter(this.props.proposalId, user.userid);
message.success(
<>
Arbiter set for <b>{this.props.title}</b>
Arbiter nominated for <b>{this.props.title}</b>
</>,
);
} catch (e) {

View File

@ -30,7 +30,7 @@ class Home extends React.Component {
<div>
<Icon type="exclamation-circle" /> There are <b>{proposalNoArbiterCount}</b>{' '}
live proposals <b>without an arbiter</b>.{' '}
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=OTHER_ARBITER">
<Link to="/proposals?filters[]=STATUS_LIVE&filters[]=ARBITER_MISSING">
Click here
</Link>{' '}
to view them.

View File

@ -16,7 +16,7 @@ import {
import TextArea from 'antd/lib/input/TextArea';
import store from 'src/store';
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 Back from 'components/Back';
import Info from 'components/Info';
@ -68,6 +68,7 @@ class ProposalDetailNaked extends React.Component<Props, State> {
type: 'default',
className: 'ProposalDetail-controls-control',
block: true,
disabled: p.status !== PROPOSAL_STATUS.LIVE
}}
/>
);
@ -209,13 +210,13 @@ class ProposalDetailNaked extends React.Component<Props, State> {
/>
);
const renderSetArbiter = () =>
!p.arbiter &&
const renderNominateArbiter = () =>
PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status &&
p.status === PROPOSAL_STATUS.LIVE && (
<Alert
showIcon
type="warning"
message="No Arbiter on Live Proposal"
message="No arbiter on live proposal"
description={
<div>
<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) => (
<div className="ProposalDetail-deet">
<span>{name}</span>
@ -242,7 +262,8 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderApproved()}
{renderReview()}
{renderRejected()}
{renderSetArbiter()}
{renderNominateArbiter()}
{renderNominatedArbiter()}
<Collapse defaultActiveKey={['brief', 'content']}>
<Collapse.Panel key="brief" header="brief">
{p.brief}
@ -279,11 +300,18 @@ class ProposalDetailNaked extends React.Component<Props, State> {
{renderDeetItem('contributed', p.contributed)}
{renderDeetItem('funded (inc. matching)', p.funded)}
{renderDeetItem('matching', p.contributionMatching)}
{p.arbiter &&
renderDeetItem(
'arbiter',
<Link to={`/users/${p.arbiter.userid}`}>{p.arbiter.displayName}</Link>,
)}
{renderDeetItem(
'arbiter',
<>
{p.arbiter.user && (
<Link to={`/users/${p.arbiter.user.userid}`}>
{p.arbiter.user.displayName}
</Link>
)}
({p.arbiter.status})
</>,
)}
{p.rfp &&
renderDeetItem(
'rfp',

View File

@ -36,6 +36,17 @@ export interface RFPArgs {
category: 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
export enum PROPOSAL_STATUS {
DRAFT = 'DRAFT',
@ -68,7 +79,7 @@ export interface Proposal {
rejectReason: string;
contributionMatching: number;
rfp?: RFP;
arbiter?: User;
arbiter: ProposalArbiter;
}
export interface Comment {
commentId: string;

View File

@ -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 {
id: string;
@ -29,14 +34,14 @@ const PROPOSAL_FILTERS = PROPOSAL_STATUSES.map(s => ({
group: 'Status',
}))
// proposal has extra filters
.concat([
{
id: `OTHER_ARBITER`,
display: `Other: Arbiter`,
color: '#cf00d5',
group: 'Other',
},
]);
.concat(
PROPOSAL_ARBITER_STATUSES.map(s => ({
id: `ARBITER_${s.id}`,
display: `Arbiter: ${s.tagDisplay}`,
color: s.tagColor,
group: 'Arbiter',
})),
);
export const proposalFilters: Filters = {
list: PROPOSAL_FILTERS,

View File

@ -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> {
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>> = [
{
id: RFP_STATUS.DRAFT,

View File

@ -7,6 +7,7 @@ from grant.email.send import generate_email, send_email
from grant.extensions import db
from grant.proposal.models import (
Proposal,
ProposalArbiter,
ProposalContribution,
proposals_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.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout
from grant.utils.misc import make_url
from grant.utils.enums import ProposalStatus, ContributionStatus
from grant.utils.enums import ProposalStatus, ContributionStatus, ProposalArbiterStatus
from grant.utils import pagination
from sqlalchemy import func, or_
@ -61,8 +62,9 @@ def stats():
.filter(Proposal.status == ProposalStatus.PENDING) \
.scalar()
proposal_no_arbiter_count = db.session.query(func.count(Proposal.id)) \
.join(Proposal.arbiter) \
.filter(Proposal.status == ProposalStatus.LIVE) \
.filter(Proposal.arbiter_id == None) \
.filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \
.scalar()
return {
"userCount": user_count,
@ -156,16 +158,17 @@ def set_arbiter(proposal_id, user_id):
if not user:
return {"message": "User not found"}, 404
if proposal.arbiter_id != user.id:
# send email
send_email(user.email_address, 'proposal_arbiter', {
'proposal': proposal,
'proposal_url': make_url(f'/proposals/{proposal.id}'),
'arbitration_url': make_url(f'/profile/{user.id}?tab=arbitration'),
})
proposal.arbiter_id = user.id
db.session.add(proposal)
db.session.commit()
# send email
code = user.email_verification.code
send_email(user.email_address, 'proposal_arbiter', {
'proposal': proposal,
'proposal_url': make_url(f'/proposals/{proposal.id}'),
'accept_url': make_url(f'/email/arbiter?code={code}&proposalId={proposal.id}'),
})
proposal.arbiter.user = user
proposal.arbiter.status = ProposalArbiterStatus.NOMINATED
db.session.add(proposal.arbiter)
db.session.commit()
return {
'proposal': proposal_schema.dump(proposal),

View File

@ -156,9 +156,9 @@ def comment_reply(email_args):
def proposal_arbiter(email_args):
return {
'subject': f'You are now arbiter of {email_args["proposal"].title}',
'title': f'You are an Arbiter',
'preview': f'Congratulations, you have been promoted to arbiter of {email_args["proposal"].title}!',
'subject': f'You have been nominated for arbiter of {email_args["proposal"].title}',
'title': f'Arbiter Nomination',
'preview': f'Congratulations, you have been nominated for arbiter of {email_args["proposal"].title}!',
'subscription': EmailSubscription.ARBITER,
}

View File

@ -2,6 +2,7 @@ from flask import Blueprint
from flask_yoloapi import endpoint
from .models import EmailVerification, db
from grant.utils.enums import ProposalArbiterStatus
blueprint = Blueprint("email", __name__, url_prefix="/api/v1/email")
@ -14,8 +15,8 @@ def verify_email(code):
ev.has_verified = True
db.session.commit()
return {"message": "Email verified"}, 200
else:
return {"message": "Invalid email code"}, 400
return {"message": "Invalid email code"}, 400
@blueprint.route("/<code>/unsubscribe", methods=["POST"])
@ -26,5 +27,21 @@ def unsubscribe_email(code):
ev.user.settings.unsubscribe_emails()
db.session.commit()
return {"message": "Unsubscribed from all emails"}, 200
else:
return {"message": "Invalid email code"}, 400
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

View File

@ -10,7 +10,7 @@ from grant.extensions import ma, db
from grant.utils.exceptions import ValidationException
from grant.utils.misc import dt_to_unix, make_url
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
proposal_team = db.Table(
@ -150,13 +150,46 @@ class ProposalContribution(db.Model):
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):
__tablename__ = "proposal"
id = db.Column(db.Integer(), primary_key=True)
date_created = db.Column(db.DateTime)
rfp_id = db.Column(db.Integer(), db.ForeignKey('rfp.id'), nullable=True)
arbiter_id = db.Column(db.Integer(), db.ForeignKey('user.id'), nullable=True)
# Content info
status = db.Column(db.String(255), nullable=False)
@ -183,7 +216,7 @@ class Proposal(db.Model):
contributions = db.relationship(ProposalContribution, backref="proposal", lazy=True, cascade="all, delete-orphan")
milestones = db.relationship("Milestone", backref="proposal", lazy=True, cascade="all, delete-orphan")
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
arbiter = db.relationship("User", lazy=True, back_populates="arbitrated_proposals")
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
def __init__(
self,
@ -238,10 +271,19 @@ class Proposal(db.Model):
@staticmethod
def create(**kwargs):
Proposal.validate(kwargs)
return Proposal(
proposal = Proposal(
**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
def get_by_user(user, statuses=[ProposalStatus.LIVE]):
status_filter = or_(Proposal.status == v for v in statuses)
@ -420,7 +462,7 @@ class ProposalSchema(ma.Schema):
milestones = ma.Nested("MilestoneSchema", many=True)
invites = ma.Nested("ProposalTeamInviteSchema", many=True)
rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"])
arbiter = ma.Nested("UserSchema") # exclude=["arbitrated_proposals"])
arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"])
def get_proposal_id(self, obj):
return obj.id
@ -564,3 +606,21 @@ user_proposal_contribution_schema = ProposalContributionSchema(exclude=['user',
user_proposal_contributions_schema = ProposalContributionSchema(many=True, exclude=['user', 'addresses'])
proposal_proposal_contribution_schema = ProposalContributionSchema(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'])

View File

@ -1,8 +1,9 @@
<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">
{{ 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>
<table width="100%" border="0" cellspacing="0" cellpadding="0">
@ -16,13 +17,13 @@
bgcolor="{{ UI.PRIMARY }}"
>
<a
href="{{ args.arbitration_url }}"
href="{{ args.accept_url }}"
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; color: #ffffff; text-decoration: none; padding: 20px 50px; border-radius: 4px; border: 1px solid {{
UI.PRIMARY
}}; display: inline-block;"
>
View your arbitrations
Accept Nomination
</a>
</td>
</tr>

View File

@ -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.
View your arbitrations: {{ args.arbitration_url }}
Accept nomination by visiting: {{ args.accept_url }}

View File

@ -118,7 +118,7 @@ class User(db.Model, UserMixin):
lazy=True, cascade="all, delete-orphan")
roles = db.relationship('Role', secondary='roles_users',
backref=db.backref('users', lazy='dynamic'))
arbitrated_proposals = db.relationship("Proposal", lazy=True, back_populates="arbiter")
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
# TODO - add create and validate methods
@ -240,14 +240,14 @@ class SelfUserSchema(ma.Schema):
"display_name",
"userid",
"email_verified",
"arbitrated_proposals"
"arbiter_proposals",
)
social_medias = ma.Nested("SocialMediaSchema", many=True)
avatar = ma.Nested("AvatarSchema")
arbiter_proposals = ma.Nested("ProposalArbiterSchema", many=True, exclude=["user"])
userid = ma.Method("get_userid")
email_verified = ma.Method("get_email_verified")
arbitrated_proposals = ma.Nested("ProposalSchema", many=True, exclude=["arbiter"])
def get_userid(self, obj):
return obj.id

View File

@ -11,6 +11,7 @@ from grant.proposal.models import (
ProposalContribution,
user_proposal_contributions_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.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)
result["pendingProposals"] = pending_dump
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
else:
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
db.session.commit()
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)

View File

@ -68,3 +68,12 @@ class RFPStatusEnum(CustomEnum):
RFPStatus = RFPStatusEnum()
class ProposalArbiterStatusEnum(CustomEnum):
MISSING = 'MISSING'
NOMINATED = 'NOMINATED'
ACCEPTED = 'ACCEPTED'
ProposalArbiterStatus = ProposalArbiterStatusEnum()

View File

@ -1,8 +1,8 @@
import abc
from sqlalchemy import or_, and_
from grant.proposal.models import db, ma, Proposal, ProposalContribution, proposal_contributions_schema
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus
from grant.proposal.models import db, ma, Proposal, ProposalContribution, ProposalArbiter, proposal_contributions_schema
from .enums import ProposalStatus, ProposalStage, Category, ContributionStatus, ProposalArbiterStatus
def extract_filters(sw, strings):
@ -49,7 +49,7 @@ class ProposalPagination(Pagination):
self.FILTERS = [f'STATUS_{s}' for s in ProposalStatus.list()]
self.FILTERS.extend([f'STAGE_{s}' for s in ProposalStage.list()])
self.FILTERS.extend([f'CAT_{c}' for c in Category.list()])
self.FILTERS.extend(['OTHER_ARBITER'])
self.FILTERS.extend([f'ARBITER_{c}' for c in ProposalArbiterStatus.list()])
self.PAGE_SIZE = 9
self.SORT_MAP = {
'CREATED:DESC': Proposal.date_created.desc(),
@ -76,7 +76,7 @@ class ProposalPagination(Pagination):
status_filters = extract_filters('STATUS_', filters)
stage_filters = extract_filters('STAGE_', filters)
cat_filters = extract_filters('CAT_', filters)
other_filters = extract_filters('OTHER_', filters)
arbiter_filters = extract_filters('ARBITER_', filters)
if 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))
if cat_filters:
query = query.filter(Proposal.category.in_(cat_filters))
if other_filters:
query = query.filter(Proposal.arbiter_id == None)
if arbiter_filters:
query = query.join(Proposal.arbiter) \
.filter(ProposalArbiter.status.in_(arbiter_filters))
# SORT (see self.SORT_MAP)
if sort:

View File

@ -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 ###

View File

@ -1,5 +1,7 @@
from grant.utils.enums import ProposalStatus
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 ..config import BaseProposalCreatorConfig
@ -126,3 +128,19 @@ class TestAdminAPI(BaseProposalCreatorConfig):
self.assert200(resp)
self.assertEqual(resp.json["status"], ProposalStatus.REJECTED)
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

View File

@ -34,6 +34,7 @@ const VerifyEmail = loadable(() => import('pages/email-verify'), opts);
const Callback = loadable(() => import('pages/callback'), opts);
const RecoverEmail = loadable(() => import('pages/email-recover'), 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 RFPs = loadable(() => import('pages/rfps'), opts);
@ -277,6 +278,17 @@ const routeConfigs: RouteConfig[] = [
title: 'Unsubscribe email',
},
},
{
// Arbiter email
route: {
path: '/email/arbiter',
component: ArbiterEmail,
exact: true,
},
template: {
title: 'Unsubscribe email',
},
},
{
// oauth callbacks
route: {

View File

@ -133,6 +133,14 @@ export function updateUserSettings(
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> {
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`);
}
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> {
return axios.get(`/api/v1/users/social/${service}/authurl`);
}

View File

@ -1,23 +1,64 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { UserProposal } from 'types';
import { UserProposalArbiter, PROPOSAL_ARBITER_STATUS } from 'types';
import { connect } from 'react-redux';
import { AppState } from 'store/reducers';
import { updateUserArbiter } from 'api/api';
import { usersActions } from 'modules/users';
import { Button, Popconfirm, message } from 'antd';
import './ProfileArbitrated.less';
const PAS = PROPOSAL_ARBITER_STATUS;
interface OwnProps {
proposal: UserProposal;
arbiter: UserProposalArbiter;
}
interface StateProps {
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, {}> {
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 (
<div className="ProfileArbitrated">
@ -25,19 +66,29 @@ class ProfileArbitrated extends React.Component<Props, {}> {
<Link to={`/proposals/${proposalId}`} className="ProfileArbitrated-title">
{title}
</Link>
<div className={`ProfileArbitrated-info`}>
You are the arbiter for this proposal. You are responsible for reviewing
milestone payout requests.
</div>
</div>
<div className="ProfileArbitrated-block is-actions">
{/* TODO - review milestone button & etc. */}
<div className={`ProfileArbitrated-info`}>{info[status]}</div>
</div>
<div className="ProfileArbitrated-block is-actions">{actions[status]}</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 => ({
user: state.auth.user,
}))(ProfileArbitrated);
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
user: state.auth.user,
}),
{ fetchUser: usersActions.fetchUser },
)(ProfileArbitrated);

View File

@ -24,9 +24,9 @@ import Loader from 'components/Loader';
import ExceptionPage from 'components/ExceptionPage';
import ContributionModal from 'components/ContributionModal';
import LinkableTabs from 'components/LinkableTabs';
import './style.less';
import { UserContribution } from 'types';
import ProfileArbitrated from './ProfileArbitrated';
import './style.less';
interface StateProps {
usersMap: AppState['users']['map'];
@ -206,7 +206,7 @@ class Profile extends React.Component<Props, State> {
/>
)}
{arbitrated.map(arb => (
<ProfileArbitrated key={arb.proposalId} proposal={arb} />
<ProfileArbitrated key={arb.proposal.proposalId} arbiter={arb} />
))}
</Tabs.TabPane>
)}

View File

@ -1,4 +1,4 @@
import { ProposalDraft, CreateMilestone, STATUS } from 'types';
import { ProposalDraft, CreateMilestone, STATUS, PROPOSAL_ARBITER_STATUS } from 'types';
import { User } from 'types';
import { getAmountError, isValidAddress } from 'utils/validators';
import { MILESTONE_STATE, Proposal } from 'types';
@ -192,6 +192,9 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): Proposal {
stage: 'preview',
category: draft.category || PROPOSAL_CATEGORY.DAPP,
isStaked: true,
arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
},
milestones: draft.milestones.map((m, idx) => ({
index: idx,
title: m.title,

View File

@ -5,6 +5,7 @@ import {
UserComment,
UserContribution,
TeamInviteWithProposal,
UserProposalArbiter,
} from 'types';
import types from './types';
@ -20,7 +21,7 @@ export interface UserState extends User {
isUpdating: boolean;
updateError: string | null;
pendingProposals: UserProposal[];
arbitrated: UserProposal[];
arbitrated: UserProposalArbiter[];
proposals: UserProposal[];
contributions: UserContribution[];
comments: UserComment[];

View File

@ -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);

View File

@ -30,7 +30,10 @@ export function formatUserFromGet(user: UserState) {
user.pendingProposals = user.pendingProposals.map(bnUserProp);
}
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.contributions = user.contributions.map(c => {

View File

@ -5,6 +5,7 @@ import {
Proposal,
ProposalMilestone,
STATUS,
PROPOSAL_ARBITER_STATUS,
} from 'types';
import { PROPOSAL_CATEGORY } from 'api/constants';
import BN from 'bn.js';
@ -160,6 +161,17 @@ export function generateProposal({
stage: 'FUNDING_REQUIRED',
category: PROPOSAL_CATEGORY.COMMUNITY,
isStaked: true,
arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
user: {
userid: 999,
displayName: 'Test Arbiter',
title: '',
emailAddress: 'test@arbiter.com',
avatar: null,
socialMedias: [],
},
},
team: [
{
userid: 123,

View File

@ -20,6 +20,15 @@ export interface Contributor {
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 {
proposalId: number;
dateCreated: number;
@ -49,6 +58,7 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
milestones: ProposalMilestone[];
datePublished: number | null;
dateApproved: number | null;
arbiter: ProposalProposalArbiter;
}
export interface TeamInviteWithProposal extends TeamInvite {
@ -96,3 +106,9 @@ export enum STATUS {
LIVE = 'LIVE',
DELETED = 'DELETED',
}
export enum PROPOSAL_ARBITER_STATUS {
MISSING = 'MISSING',
NOMINATED = 'NOMINATED',
ACCEPTED = 'ACCEPTED',
}