From 9831bc03dbae0ca3f4b78ed658dd7ad853955c8e Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 9 Feb 2019 20:58:40 -0600 Subject: [PATCH 1/6] BE: more robust arbiter + arbiter status + arbiter accept email code --- backend/grant/admin/views.py | 26 +++---- backend/grant/email/send.py | 6 +- backend/grant/email/views.py | 24 +++++-- backend/grant/proposal/models.py | 70 +++++++++++++++++-- .../templates/emails/proposal_arbiter.html | 9 +-- .../templates/emails/proposal_arbiter.txt | 4 +- backend/grant/user/models.py | 6 +- backend/grant/user/views.py | 28 +++++++- backend/grant/utils/enums.py | 9 +++ backend/grant/utils/pagination.py | 12 ++-- backend/migrations/versions/86d300cb6d69_.py | 40 +++++++++++ backend/tests/admin/test_api.py | 18 +++++ 12 files changed, 212 insertions(+), 40 deletions(-) create mode 100644 backend/migrations/versions/86d300cb6d69_.py diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index c5fab00d..44416db2 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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_ @@ -62,7 +63,7 @@ def stats(): .scalar() proposal_no_arbiter_count = db.session.query(func.count(Proposal.id)) \ .filter(Proposal.status == ProposalStatus.LIVE) \ - .filter(Proposal.arbiter_id == None) \ + .filter(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \ .scalar() return { "userCount": user_count, @@ -156,16 +157,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), diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 3c4cea5e..5dd1ac2b 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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, } diff --git a/backend/grant/email/views.py b/backend/grant/email/views.py index 568ad647..2bf712b1 100644 --- a/backend/grant/email/views.py +++ b/backend/grant/email/views.py @@ -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("//unsubscribe", methods=["POST"]) @@ -26,5 +27,20 @@ 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("//arbiter/", 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 diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 31336030..8806b127 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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.commit() + + 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']) diff --git a/backend/grant/templates/emails/proposal_arbiter.html b/backend/grant/templates/emails/proposal_arbiter.html index ff7c210a..17ce49f3 100644 --- a/backend/grant/templates/emails/proposal_arbiter.html +++ b/backend/grant/templates/emails/proposal_arbiter.html @@ -1,8 +1,9 @@

- You have been made arbiter of + You have been nominated for arbiter of {{ args.proposal.title }} . You will be responsible for reviewing milestone payout requests. + >. You will be responsible for reviewing milestone payout requests should you + choose to accept.

@@ -16,13 +17,13 @@ bgcolor="{{ UI.PRIMARY }}" > - View your arbitrations + Accept Nomination diff --git a/backend/grant/templates/emails/proposal_arbiter.txt b/backend/grant/templates/emails/proposal_arbiter.txt index 97ecf222..f8c41c50 100644 --- a/backend/grant/templates/emails/proposal_arbiter.txt +++ b/backend/grant/templates/emails/proposal_arbiter.txt @@ -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 }} diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 2b4cf2b2..77cf500a 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -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 @@ -237,14 +237,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 diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index edd2404c..63904cc3 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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) @@ -365,3 +367,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("//arbiter/", 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) diff --git a/backend/grant/utils/enums.py b/backend/grant/utils/enums.py index e181ca86..167beeeb 100644 --- a/backend/grant/utils/enums.py +++ b/backend/grant/utils/enums.py @@ -68,3 +68,12 @@ class RFPStatusEnum(CustomEnum): RFPStatus = RFPStatusEnum() + + +class ProposalArbiterStatusEnum(CustomEnum): + MISSING = 'MISSING' + NOMINATED = 'NOMINATED' + ACCEPTED = 'ACCEPTED' + + +ProposalArbiterStatus = ProposalArbiterStatusEnum() diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 47c7f178..9d0f6a20 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -1,8 +1,8 @@ import abc from sqlalchemy import or_, and_ -from grant.proposal.models import db, ma, Proposal, 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,8 @@ 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.filter(ProposalArbiter.status.in_(arbiter_filters)) # SORT (see self.SORT_MAP) if sort: diff --git a/backend/migrations/versions/86d300cb6d69_.py b/backend/migrations/versions/86d300cb6d69_.py new file mode 100644 index 00000000..9797292e --- /dev/null +++ b/backend/migrations/versions/86d300cb6d69_.py @@ -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 ### diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index dbef1ce6..db31863d 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -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 From 5b44f5dcd7cf2c85de48106a4f0c7fcd2832d9b5 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 9 Feb 2019 21:00:49 -0600 Subject: [PATCH 2/6] admin: more robust arbiter --- admin/src/components/ArbiterControl/index.tsx | 17 +++++-- admin/src/components/Home/index.tsx | 2 +- admin/src/components/ProposalDetail/index.tsx | 47 +++++++++++++++---- admin/src/types.ts | 13 ++++- admin/src/util/filters.ts | 23 +++++---- admin/src/util/statuses.ts | 28 ++++++++++- 6 files changed, 103 insertions(+), 27 deletions(-) diff --git a/admin/src/components/ArbiterControl/index.tsx b/admin/src/components/ArbiterControl/index.tsx index 70a414e0..54a9217a 100644 --- a/admin/src/components/ArbiterControl/index.tsx +++ b/admin/src/components/ArbiterControl/index.tsx @@ -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 { 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 { onClick={this.handleShowSearch} {...this.props.buttonProps} > - {arbiter ? 'Change arbiter' : 'Set arbiter'} + {disp[arbiter.status]} {/* SEARCH MODAL */} {showSearch && ( - Select an arbiter + Nominate an arbiter } visible={true} @@ -96,7 +103,7 @@ class ArbiterControlNaked extends React.Component { key="select" onClick={() => this.handleSelect(u)} > - Select + Nominate , ]} > @@ -143,7 +150,7 @@ class ArbiterControlNaked extends React.Component { await store.setArbiter(this.props.proposalId, user.userid); message.success( <> - Arbiter set for {this.props.title} + Arbiter nominated for {this.props.title} , ); } catch (e) { diff --git a/admin/src/components/Home/index.tsx b/admin/src/components/Home/index.tsx index 8bba0039..b6a08aa4 100644 --- a/admin/src/components/Home/index.tsx +++ b/admin/src/components/Home/index.tsx @@ -30,7 +30,7 @@ class Home extends React.Component {
There are {proposalNoArbiterCount}{' '} live proposals without an arbiter.{' '} - + Click here {' '} to view them. diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index cbabaee4..fb9b1002 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -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'; @@ -209,13 +209,13 @@ class ProposalDetailNaked extends React.Component { /> ); - const renderSetArbiter = () => - !p.arbiter && + const renderNominateArbiter = () => + PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status && p.status === PROPOSAL_STATUS.LIVE && (

An arbiter is required to review milestone payout requests.

@@ -225,6 +225,25 @@ class ProposalDetailNaked extends React.Component { /> ); + const renderNominatedArbiter = () => + PROPOSAL_ARBITER_STATUS.NOMINATED === p.arbiter.status && + p.status === PROPOSAL_STATUS.LIVE && ( + +

+ {p.arbiter.user!.displayName} has been nominated for arbiter of + this proposal but has not yet accepted. +

+ +
+ } + /> + ); + const renderDeetItem = (name: string, val: any) => (
{name} @@ -242,7 +261,8 @@ class ProposalDetailNaked extends React.Component { {renderApproved()} {renderReview()} {renderRejected()} - {renderSetArbiter()} + {renderNominateArbiter()} + {renderNominatedArbiter()} {p.brief} @@ -279,11 +299,18 @@ class ProposalDetailNaked extends React.Component { {renderDeetItem('contributed', p.contributed)} {renderDeetItem('funded (inc. matching)', p.funded)} {renderDeetItem('matching', p.contributionMatching)} - {p.arbiter && - renderDeetItem( - 'arbiter', - {p.arbiter.displayName}, - )} + + {renderDeetItem( + 'arbiter', + <> + {p.arbiter.user && ( + + {p.arbiter.user.displayName} + + )} + ({p.arbiter.status}) + , + )} {p.rfp && renderDeetItem( 'rfp', diff --git a/admin/src/types.ts b/admin/src/types.ts index eb634d3b..0473d561 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -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; diff --git a/admin/src/util/filters.ts b/admin/src/util/filters.ts index 4f05b92c..df91f8d2 100644 --- a/admin/src/util/filters.ts +++ b/admin/src/util/filters.ts @@ -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, diff --git a/admin/src/util/statuses.ts b/admin/src/util/statuses.ts index 1c786855..c9e461f0 100644 --- a/admin/src/util/statuses.ts +++ b/admin/src/util/statuses.ts @@ -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 { id: E; @@ -53,6 +58,27 @@ export const PROPOSAL_STATUSES: Array> = [ }, ]; +export const PROPOSAL_ARBITER_STATUSES: Array> = [ + { + 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> = [ { id: RFP_STATUS.DRAFT, From 7a036058f626712e6fb178f538b619c1df3f3c89 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 9 Feb 2019 21:03:19 -0600 Subject: [PATCH 3/6] FE: more robust arbiter + arbiter accept email validation code page --- frontend/client/Routes.tsx | 12 +++ frontend/client/api/api.ts | 12 +++ .../components/Profile/ProfileArbitrated.tsx | 79 +++++++++++++--- frontend/client/components/Profile/index.tsx | 4 +- frontend/client/modules/create/utils.ts | 5 +- frontend/client/modules/users/reducers.ts | 3 +- frontend/client/pages/email-arbiter.tsx | 92 +++++++++++++++++++ frontend/client/utils/api.ts | 5 +- frontend/stories/props.tsx | 12 +++ frontend/types/proposal.ts | 16 ++++ 10 files changed, 221 insertions(+), 19 deletions(-) create mode 100644 frontend/client/pages/email-arbiter.tsx diff --git a/frontend/client/Routes.tsx b/frontend/client/Routes.tsx index d3595661..03425289 100644 --- a/frontend/client/Routes.tsx +++ b/frontend/client/Routes.tsx @@ -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: { diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index e74eade3..d14eb4ec 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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 { + return axios.put(`/api/v1/users/${userId}/arbiter/${proposalId}`, { isAccept }); +} + export function requestUserRecoveryEmail(email: string): Promise { return axios.post(`/api/v1/users/recover`, { email }); } @@ -149,6 +157,10 @@ export function unsubscribeEmail(code: string): Promise { return axios.post(`/api/v1/email/${code}/unsubscribe`); } +export function arbiterEmail(code: string, proposalId: number): Promise { + return axios.post(`/api/v1/email/${code}/arbiter/${proposalId}`); +} + export function getSocialAuthUrl(service: SOCIAL_SERVICE): Promise { return axios.get(`/api/v1/users/social/${service}/authurl`); } diff --git a/frontend/client/components/Profile/ProfileArbitrated.tsx b/frontend/client/components/Profile/ProfileArbitrated.tsx index 9abe175f..191eec30 100644 --- a/frontend/client/components/Profile/ProfileArbitrated.tsx +++ b/frontend/client/components/Profile/ProfileArbitrated.tsx @@ -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 { 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{' '} + this.acceptArbiter(false)} + > + opt out + {' '} + at any time. + + ), + }; + + const actions = { + [PAS.MISSING]: <>{/* nada */}, + [PAS.NOMINATED]: ( + <> + + + + ), + [PAS.ACCEPTED]: <>{/* TODO - milestone payout approvals */}, + }; return (
@@ -25,19 +66,29 @@ class ProfileArbitrated extends React.Component { {title} -
- You are the arbiter for this proposal. You are responsible for reviewing - milestone payout requests. -
-
-
- {/* TODO - review milestone button & etc. */} +
{info[status]}
+
{actions[status]}
); } + + 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(state => ({ - user: state.auth.user, -}))(ProfileArbitrated); +export default connect( + state => ({ + user: state.auth.user, + }), + { fetchUser: usersActions.fetchUser }, +)(ProfileArbitrated); diff --git a/frontend/client/components/Profile/index.tsx b/frontend/client/components/Profile/index.tsx index 53ca829b..a1219b58 100644 --- a/frontend/client/components/Profile/index.tsx +++ b/frontend/client/components/Profile/index.tsx @@ -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 { /> )} {arbitrated.map(arb => ( - + ))} )} diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 9ba03af1..b1ff6be3 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -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, diff --git a/frontend/client/modules/users/reducers.ts b/frontend/client/modules/users/reducers.ts index a843f347..3dd319f4 100644 --- a/frontend/client/modules/users/reducers.ts +++ b/frontend/client/modules/users/reducers.ts @@ -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[]; diff --git a/frontend/client/pages/email-arbiter.tsx b/frontend/client/pages/email-arbiter.tsx new file mode 100644 index 00000000..fd17fa94 --- /dev/null +++ b/frontend/client/pages/email-arbiter.tsx @@ -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 { + 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 = ( +
+ + + + + + +
+ ); + + if (hasAccepted) { + return ( + + ); + } else if (error) { + return ( + + ); + } else { + return ; + } + } +} + +export default withRouter(ArbiterEmail); diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 3170b492..2a8686d2 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -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 => { diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index ac60ef25..63ae1635 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -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, diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index f2fec571..64b1c66c 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -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; +export type UserProposalArbiter = Omit; + export interface ProposalDraft { proposalId: number; dateCreated: number; @@ -49,6 +58,7 @@ export interface Proposal extends Omit { 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', +} From 6a0961a0150a4f3a2a05cbd7c1952ca26f08684d Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 9 Feb 2019 21:18:26 -0600 Subject: [PATCH 4/6] disable arbiter button when proposal status not LIVE --- admin/src/components/ProposalDetail/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index fb9b1002..e955cef1 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -68,6 +68,7 @@ class ProposalDetailNaked extends React.Component { type: 'default', className: 'ProposalDetail-controls-control', block: true, + disabled: p.status !== PROPOSAL_STATUS.LIVE }} /> ); From 5ec50718dd88224262cf2bec9c89f0f3f775d805 Mon Sep 17 00:00:00 2001 From: Aaron Date: Sat, 9 Feb 2019 22:03:55 -0600 Subject: [PATCH 5/6] fix indentation bug --- backend/grant/email/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/grant/email/views.py b/backend/grant/email/views.py index 2bf712b1..f2e19ebe 100644 --- a/backend/grant/email/views.py +++ b/backend/grant/email/views.py @@ -41,6 +41,7 @@ def accept_arbiter(code, proposal_id): 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": "No nomination for this code"}, 404 return {"message": "Invalid email code"}, 400 From 72c3d6b50702982b8b6c282ecf5f4209e7430817 Mon Sep 17 00:00:00 2001 From: Aaron Date: Mon, 11 Feb 2019 15:59:29 -0600 Subject: [PATCH 6/6] join arbiter filter queries + use flush to get proposal.id on create --- backend/grant/admin/views.py | 1 + backend/grant/proposal/models.py | 2 +- backend/grant/utils/pagination.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 44416db2..23ac8cb9 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -62,6 +62,7 @@ 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(ProposalArbiter.status == ProposalArbiterStatus.MISSING) \ .scalar() diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 8806b127..36d42316 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -277,7 +277,7 @@ class Proposal(db.Model): # arbiter needs proposal.id db.session.add(proposal) - db.session.commit() + db.session.flush() arbiter = ProposalArbiter(proposal_id=proposal.id) db.session.add(arbiter) diff --git a/backend/grant/utils/pagination.py b/backend/grant/utils/pagination.py index 9d0f6a20..d75c50e8 100644 --- a/backend/grant/utils/pagination.py +++ b/backend/grant/utils/pagination.py @@ -87,7 +87,8 @@ class ProposalPagination(Pagination): if cat_filters: query = query.filter(Proposal.category.in_(cat_filters)) if arbiter_filters: - query = query.filter(ProposalArbiter.status.in_(arbiter_filters)) + query = query.join(Proposal.arbiter) \ + .filter(ProposalArbiter.status.in_(arbiter_filters)) # SORT (see self.SORT_MAP) if sort: