diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml new file mode 100644 index 00000000..64f82163 --- /dev/null +++ b/.github/workflows/node.js.yml @@ -0,0 +1,32 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Node.js CI + +on: + push: + branches: + - develop + - master + pull_request: + branches: + - develop + - master + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [12.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: cd frontend && yarn && && yarn run lint && yarn run tsc diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 00000000..99f9547a --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,32 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: + - develop + - master + pull_request: + branches: + - develop + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.7 + uses: actions/setup-python@v2 + with: + python-version: 3.7 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + cd backend && pip install -r requirements/dev.txt + - name: Test with flask test + run: | + cd backend && cp .env.example .env && flask test diff --git a/admin/src/components/ProposalDetail/index.tsx b/admin/src/components/ProposalDetail/index.tsx index 80065bf9..389adbfe 100644 --- a/admin/src/components/ProposalDetail/index.tsx +++ b/admin/src/components/ProposalDetail/index.tsx @@ -2,27 +2,11 @@ import React from 'react'; import BN from 'bn.js'; import { view } from 'react-easy-state'; import { RouteComponentProps, withRouter } from 'react-router'; -import { - Alert, - Button, - Card, - Col, - Collapse, - Input, - message, - Popconfirm, - Row, - Tag, -} from 'antd'; +import { Alert, Button, Card, Col, Collapse, Input, message, Popconfirm, Row, Switch, Tag } from 'antd'; import TextArea from 'antd/lib/input/TextArea'; import store from 'src/store'; import { formatDateSeconds, formatDurationSeconds } from 'util/time'; -import { - MILESTONE_STAGE, - PROPOSAL_ARBITER_STATUS, - PROPOSAL_STAGE, - PROPOSAL_STATUS, -} from 'src/types'; +import { MILESTONE_STAGE, PROPOSAL_ARBITER_STATUS, PROPOSAL_STAGE, PROPOSAL_STATUS } from 'src/types'; import { Link } from 'react-router-dom'; import Back from 'components/Back'; import Markdown from 'components/Markdown'; @@ -30,6 +14,7 @@ import ArbiterControl from 'components/ArbiterControl'; import { fromZat, toZat } from 'src/util/units'; import FeedbackModal from '../FeedbackModal'; import { formatUsd } from 'util/formatters'; + import './index.less'; type Props = RouteComponentProps; @@ -58,6 +43,8 @@ class ProposalDetailNaked extends React.Component { return 'loading proposal...'; } + console.log(p.fundedByZomg); + const needsArbiter = PROPOSAL_ARBITER_STATUS.MISSING === p.arbiter.status && p.status === PROPOSAL_STATUS.LIVE && @@ -94,9 +81,9 @@ class ProposalDetailNaked extends React.Component {
Please review this proposal and render your judgment.
@@ -346,8 +333,8 @@ class ProposalDetailNaked extends React.Component { p.changesRequestedDiscussion && ( @@ -360,10 +347,10 @@ class ProposalDetailNaked extends React.Component { Mark Request as Resolved @@ -381,8 +368,8 @@ class ProposalDetailNaked extends React.Component { {!p.kycApproved ? ( @@ -390,10 +377,10 @@ class ProposalDetailNaked extends React.Component { with payouts. this.handleApproveKYC()} > KYC Approved @@ -404,8 +391,8 @@ class ProposalDetailNaked extends React.Component { ) : ( An arbiter is required to review milestone payout requests. @@ -422,8 +409,8 @@ class ProposalDetailNaked extends React.Component { p.status === PROPOSAL_STATUS.LIVE && ( @@ -469,9 +456,9 @@ class ProposalDetailNaked extends React.Component { return ( @@ -487,9 +474,9 @@ class ProposalDetailNaked extends React.Component { {' '} {p.payoutAddress} this.setState({ paidTxId: e.target.value })} onSearch={this.handlePaidMilestone} /> @@ -503,7 +490,7 @@ class ProposalDetailNaked extends React.Component { p.isFailed && ( { ); const renderDeetItem = (name: string, val: any) => ( - + {name} {val} ); - console.log(p); - + // @ts-ignore return ( - - + + {p.title} {/* MAIN */} @@ -550,22 +536,22 @@ class ProposalDetailNaked extends React.Component { {renderMilestoneAccepted()} {renderFailed()} - + {p.brief} - + - + {p.milestones.map((milestone, i) => ( {milestone.title + ' '} {milestone.immediatePayout && ( - Immediate Payout + Immediate Payout )} > } @@ -590,7 +576,7 @@ class ProposalDetailNaked extends React.Component { ))} - + {JSON.stringify(p, null, 4)} @@ -599,26 +585,38 @@ class ProposalDetailNaked extends React.Component { {/* RIGHT SIDE */} {p.isVersionTwo && - !p.acceptedWithFunding && - p.stage === PROPOSAL_STAGE.WIP && ( - - )} + !p.acceptedWithFunding && + p.stage === PROPOSAL_STAGE.WIP && ( + + )} {/* ACTIONS */} - + {renderCancelControl()} {renderArbiterControl()} + + { + p.acceptedWithFunding && + + + + } + {shouldShowChangeToAcceptedWithFunding && - renderChangeToAcceptedWithFundingControl()} + renderChangeToAcceptedWithFundingControl()} {/* DETAILS */} - + {renderDeetItem('id', p.proposalId)} {renderDeetItem('created', formatDateSeconds(p.dateCreated))} {renderDeetItem( @@ -630,10 +628,10 @@ class ProposalDetailNaked extends React.Component { formatDurationSeconds(p.deadlineDuration), )} {p.datePublished && - renderDeetItem( - '(deadline)', - formatDateSeconds(p.datePublished + p.deadlineDuration), - )} + renderDeetItem( + '(deadline)', + formatDateSeconds(p.datePublished + p.deadlineDuration), + )} {renderDeetItem('isFailed', JSON.stringify(p.isFailed))} {renderDeetItem('status', p.status)} {renderDeetItem('stage', p.stage)} @@ -662,14 +660,14 @@ class ProposalDetailNaked extends React.Component { >, )} {p.rfp && - renderDeetItem( - 'rfp', - {p.rfp.title}, - )} + renderDeetItem( + 'rfp', + {p.rfp.title}, + )} {/* TEAM */} - + {p.team.map(t => ( {t.displayName} @@ -783,6 +781,10 @@ class ProposalDetailNaked extends React.Component { await store.markMilestonePaid(pid, mid, this.state.paidTxId); message.success('Marked milestone paid.'); }; + + private handleSwitchFunder = async (checkValue: boolean) => { + store.switchProposalFunder(checkValue); + }; } const ProposalDetail = withRouter(view(ProposalDetailNaked)); diff --git a/admin/src/store.ts b/admin/src/store.ts index fc9a9818..489e3ae2 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -142,6 +142,11 @@ async function approveDiscussion( return data; } +async function switchProposalFunder(id: number, fundedByZomg: boolean) { + const { data } = await api.put(`/admin/proposals/${id}/adjust-funder`, {fundedByZomg}); + return data; +} + async function approveProposalKYC(id: number) { const { data } = await api.put(`/admin/proposals/${id}/approve-kyc`); return data; @@ -351,6 +356,7 @@ const app = store({ proposalDetailMarkingChangesAsResolved: false, proposalDetailAcceptingProposal: false, proposalDetailApprovingKyc: false, + proposalDetailSwitchingFunder: false, proposalDetailMarkingMilestonePaid: false, proposalDetailCanceling: false, proposalDetailUpdating: false, @@ -695,6 +701,24 @@ const app = store({ } }, + async switchProposalFunder(fundedByZomg: boolean) { + if (!app.proposalDetail) { + const m = 'store.acceptProposal(): Expected proposalDetail to be populated!'; + app.generalError.push(m); + console.error(m); + return; + } + app.proposalDetailSwitchingFunder = true; + try { + const { proposalId } = app.proposalDetail; + const res = await switchProposalFunder(proposalId, fundedByZomg); + app.updateProposalInStore(res); + } catch (e) { + handleApiError(e); + } + app.proposalDetailSwitchingFunder = false; + }, + async approveProposalKYC() { if (!app.proposalDetail) { const m = 'store.acceptProposal(): Expected proposalDetail to be populated!'; diff --git a/admin/src/types.ts b/admin/src/types.ts index 2aae7174..77669b33 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -124,6 +124,7 @@ export interface Proposal { changesRequestedDiscussion: boolean | null; changesRequestedDiscussionReason: string | null; kycApproved: null | boolean; + fundedByZomg: boolean; } export interface Comment { id: number; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index b394062b..21545ec7 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -4,7 +4,7 @@ from functools import reduce from flask import Blueprint, request from marshmallow import fields, validate -from sqlalchemy import func, or_, text +from sqlalchemy import func, text import grant.utils.admin as admin import grant.utils.auth as auth @@ -25,7 +25,7 @@ from grant.proposal.models import ( admin_proposal_contributions_schema, ) from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema -from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema +from grant.user.models import User, admin_users_schema, admin_user_schema from grant.utils import pagination from grant.utils.enums import ( ProposalStatus, @@ -390,6 +390,22 @@ def approve_proposal_kyc(id): return proposal_schema.dump(proposal) +@blueprint.route('/proposals//adjust-funder', methods=['PUT']) +@body({ + "fundedByZomg": fields.Bool(required=True), +}) +@admin.admin_auth_required +def adjust_funder(id, funded_by_zomg): + proposal = Proposal.query.get(id) + if not proposal: + return {"message": "No proposal found."}, 404 + + proposal.funded_by_zomg = funded_by_zomg + db.session.add(proposal) + db.session.commit() + return proposal_schema.dump(proposal) + + @blueprint.route('/proposals//accept', methods=['PUT']) @body({ "isAccepted": fields.Bool(required=True), diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 59e3ff70..b49e2b94 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -1,8 +1,8 @@ import datetime import json -from typing import Optional from decimal import Decimal, ROUND_DOWN from functools import reduce +from typing import Optional from marshmallow import post_dump from sqlalchemy import func, or_, select, ForeignKey @@ -10,15 +10,14 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import column_property from grant.comment.models import Comment -from grant.milestone.models import Milestone from grant.email.send import send_email from grant.extensions import ma, db +from grant.milestone.models import Milestone from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX from grant.task.jobs import ContributionExpired from grant.utils.enums import ( ProposalStatus, ProposalStage, - Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage, @@ -332,7 +331,8 @@ class ProposalRevision(db.Model): if old_proposal.title != new_proposal.title: proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TITLE}) - milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones) + milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, + new_proposal.milestones) return proposal_changes + milestone_changes @@ -392,6 +392,7 @@ class Proposal(db.Model): date_published = db.Column(db.DateTime) reject_reason = db.Column(db.String()) kyc_approved = db.Column(db.Boolean(), nullable=True, default=False) + funded_by_zomg = db.Column(db.Boolean(), nullable=True, default=False) accepted_with_funding = db.Column(db.Boolean(), nullable=True) changes_requested_discussion = db.Column(db.Boolean(), nullable=True) @@ -422,21 +423,23 @@ class Proposal(db.Model): ) followers_count = column_property( select([func.count(proposal_follower.c.proposal_id)]) - .where(proposal_follower.c.proposal_id == id) - .correlate_except(proposal_follower) + .where(proposal_follower.c.proposal_id == id) + .correlate_except(proposal_follower) ) likes = db.relationship( "User", secondary=proposal_liker, back_populates="liked_proposals" ) likes_count = column_property( select([func.count(proposal_liker.c.proposal_id)]) - .where(proposal_liker.c.proposal_id == id) - .correlate_except(proposal_liker) + .where(proposal_liker.c.proposal_id == id) + .correlate_except(proposal_liker) ) live_draft_parent_id = db.Column(db.Integer, ForeignKey('proposal.id')) - live_draft = db.relationship("Proposal", uselist=False, backref=db.backref('live_draft_parent', remote_side=[id], uselist=False)) + live_draft = db.relationship("Proposal", uselist=False, + backref=db.backref('live_draft_parent', remote_side=[id], uselist=False)) - revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True, cascade="all, delete-orphan") + revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True, + cascade="all, delete-orphan") def __init__( self, @@ -527,7 +530,7 @@ class Proposal(db.Model): # Validate payout address if not is_z_address_valid(self.payout_address): raise ValidationException("Payout address is not a valid z address") - + # Validate tip jar address if self.tip_jar_address and not is_z_address_valid(self.tip_jar_address): raise ValidationException("Tip address is not a valid z address") @@ -535,7 +538,6 @@ class Proposal(db.Model): # Then run through regular validation Proposal.simple_validate(vars(self)) - def validate_milestone_days(self): for milestone in self.milestones: if milestone.immediate_payout: @@ -612,11 +614,11 @@ class Proposal(db.Model): self.rfp_opt_in = opt_in def create_contribution( - self, - amount, - user_id: int = None, - staking: bool = False, - private: bool = True, + self, + amount, + user_id: int = None, + staking: bool = False, + private: bool = True, ): contribution = ProposalContribution( proposal_id=self.id, @@ -923,8 +925,8 @@ class Proposal(db.Model): return False res = ( db.session.query(proposal_follower) - .filter_by(user_id=authed.id, proposal_id=self.id) - .count() + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() ) if res: return True @@ -939,8 +941,8 @@ class Proposal(db.Model): return False res = ( db.session.query(proposal_liker) - .filter_by(user_id=authed.id, proposal_id=self.id) - .count() + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() ) if res: return True @@ -1099,7 +1101,8 @@ class ProposalSchema(ma.Schema): "changes_requested_discussion", "changes_requested_discussion_reason", "live_draft_id", - "kyc_approved" + "kyc_approved", + "funded_by_zomg" ) date_created = ma.Method("get_date_created") @@ -1109,6 +1112,7 @@ class ProposalSchema(ma.Schema): is_version_two = ma.Method("get_is_version_two") tip_jar_view_key = ma.Method("get_tip_jar_view_key") live_draft_id = ma.Method("get_live_draft_id") + funded_by_zomg = ma.Method("get_funded_by_zomg") updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) @@ -1118,6 +1122,14 @@ class ProposalSchema(ma.Schema): rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"]) arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"]) + def get_funded_by_zomg(self, obj): + if obj.funded_by_zomg is None: + return False + elif obj.funded_by_zomg is False: + return False + else: + return True + def get_proposal_id(self, obj): return obj.id diff --git a/backend/migrations/versions/91b16dc2fd74_.py b/backend/migrations/versions/91b16dc2fd74_.py new file mode 100644 index 00000000..7d44efd7 --- /dev/null +++ b/backend/migrations/versions/91b16dc2fd74_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 91b16dc2fd74 +Revises: d03c91f3038d +Create Date: 2021-02-01 17:00:23.721765 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '91b16dc2fd74' +down_revision = 'd03c91f3038d' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('proposal', sa.Column('funded_by_zomg', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('proposal', 'funded_by_zomg') + # ### end Alembic commands ### diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts index 77d60521..19d88af9 100644 --- a/frontend/client/api/constants.ts +++ b/frontend/client/api/constants.ts @@ -86,11 +86,11 @@ export const STAGE_UI: { [key in PROPOSAL_FILTERS]: StageUI } = { color: '#8e44ad', }, ACCEPTED_WITH_FUNDING: { - label: 'Funded by ZF', + label: 'Funded', color: '#8e44ad', }, ACCEPTED_WITHOUT_FUNDING: { - label: 'Not Funded by ZF', + label: 'Not Funded', color: '#8e44ad', }, WIP: { diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx index 853cbfe4..49947dd3 100644 --- a/frontend/client/components/Profile/ProfileProposal.tsx +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { UserProposal, STATUS } from 'types'; +import { STATUS, UserProposal } from 'types'; import './ProfileProposal.less'; import UserRow from 'components/UserRow'; import UnitDisplay from 'components/UnitDisplay'; @@ -23,7 +23,8 @@ export default class Profile extends React.Component { isVersionTwo, acceptedWithFunding, status, - changesRequestedDiscussionReason + changesRequestedDiscussionReason, + fundedByZomg, } = this.props.proposal; // pulled from `variables.less` @@ -31,18 +32,24 @@ export default class Profile extends React.Component { const secondaryColor = '#2D2A26'; const isOpenForDiscussion = status === STATUS.DISCUSSION; - const discussionColor = changesRequestedDiscussionReason ? 'red' : infoColor - const discussionTag = changesRequestedDiscussionReason ? 'Changes Requested' : 'Open for Public Review' + const discussionColor = changesRequestedDiscussionReason ? 'red' : infoColor; + const discussionTag = changesRequestedDiscussionReason + ? 'Changes Requested' + : 'Open for Public Review'; - let tagColor = infoColor - let tagMessage = 'Open for Contributions' + let tagColor = infoColor; + let tagMessage = 'Open for Contributions'; if (acceptedWithFunding) { - tagColor = secondaryColor - tagMessage = 'Funded by ZF' + tagColor = secondaryColor; + if (!fundedByZomg) { + tagMessage = 'Funded by ZF'; + } else { + tagMessage = 'Funded by ZOMG'; + } } else if (isOpenForDiscussion) { - tagColor = discussionColor - tagMessage = discussionTag + tagColor = discussionColor; + tagMessage = discussionTag; } return ( diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 78c0b7f5..59bede65 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import moment from 'moment'; -import { Icon, Popover, Tooltip, Alert } from 'antd'; +import { Alert, Icon, Popover, Tooltip } from 'antd'; import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; import { connect } from 'react-redux'; @@ -12,6 +12,8 @@ import Loader from 'components/Loader'; import { PROPOSAL_STAGE } from 'api/constants'; import { formatUsd } from 'utils/formatters'; import ZFGrantsLogo from 'static/images/logo-name-light.svg'; +import ZomgLogo from 'static/images/zomg-logo.png'; + import './style.less'; interface OwnProps { @@ -134,7 +136,11 @@ export class ProposalCampaignBlock extends React.Component { isAcceptedWithFunding && ( Funded through - + {proposal.fundedByZomg ? ( + + ) : ( + + )} )} diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index c965b021..ce427158 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -29,6 +29,7 @@ export class ProposalCard extends React.Component { percentFunded, acceptedWithFunding, status, + fundedByZomg, } = this.props; // pulled from `variables.less` @@ -46,7 +47,11 @@ export class ProposalCard extends React.Component { if (isVersionTwo && status === STATUS.LIVE) { if (acceptedWithFunding) { tagColor = secondaryColor; - tagMessage = 'Funded by ZF'; + if (!fundedByZomg) { + tagMessage = 'Funded by ZF'; + } else { + tagMessage = 'Funded by ZOMG'; + } } else { tagColor = infoColor; tagMessage = 'Not Funded'; diff --git a/frontend/client/static/images/zomg-logo.png b/frontend/client/static/images/zomg-logo.png new file mode 100644 index 00000000..512d5513 Binary files /dev/null and b/frontend/client/static/images/zomg-logo.png differ diff --git a/frontend/client/static/images/zomg-logo.svg b/frontend/client/static/images/zomg-logo.svg new file mode 100644 index 00000000..22c5d9b7 --- /dev/null +++ b/frontend/client/static/images/zomg-logo.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + zomg + + + + + + + diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index ca81af18..829c9054 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -81,6 +81,7 @@ export interface Proposal extends Omit { liveDraftId: string | null; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived + fundedByZomg: boolean; } export interface TeamInviteWithProposal extends TeamInvite {
@@ -360,10 +347,10 @@ class ProposalDetailNaked extends React.Component { Mark Request as Resolved @@ -381,8 +368,8 @@ class ProposalDetailNaked extends React.Component { {!p.kycApproved ? ( @@ -390,10 +377,10 @@ class ProposalDetailNaked extends React.Component { with payouts. this.handleApproveKYC()} > KYC Approved @@ -404,8 +391,8 @@ class ProposalDetailNaked extends React.Component { ) : ( An arbiter is required to review milestone payout requests. @@ -422,8 +409,8 @@ class ProposalDetailNaked extends React.Component { p.status === PROPOSAL_STATUS.LIVE && ( @@ -469,9 +456,9 @@ class ProposalDetailNaked extends React.Component { return ( @@ -487,9 +474,9 @@ class ProposalDetailNaked extends React.Component { {' '} {p.payoutAddress} this.setState({ paidTxId: e.target.value })} onSearch={this.handlePaidMilestone} /> @@ -503,7 +490,7 @@ class ProposalDetailNaked extends React.Component { p.isFailed && ( { ); const renderDeetItem = (name: string, val: any) => ( - + {name} {val} ); - console.log(p); - + // @ts-ignore return ( - - + + {p.title} {/* MAIN */} @@ -550,22 +536,22 @@ class ProposalDetailNaked extends React.Component { {renderMilestoneAccepted()} {renderFailed()} - + {p.brief} - + - + {p.milestones.map((milestone, i) => ( {milestone.title + ' '} {milestone.immediatePayout && ( - Immediate Payout + Immediate Payout )} > } @@ -590,7 +576,7 @@ class ProposalDetailNaked extends React.Component { ))} - + {JSON.stringify(p, null, 4)} @@ -599,26 +585,38 @@ class ProposalDetailNaked extends React.Component { {/* RIGHT SIDE */} {p.isVersionTwo && - !p.acceptedWithFunding && - p.stage === PROPOSAL_STAGE.WIP && ( - - )} + !p.acceptedWithFunding && + p.stage === PROPOSAL_STAGE.WIP && ( + + )} {/* ACTIONS */} - + {renderCancelControl()} {renderArbiterControl()} + + { + p.acceptedWithFunding && + + + + } + {shouldShowChangeToAcceptedWithFunding && - renderChangeToAcceptedWithFundingControl()} + renderChangeToAcceptedWithFundingControl()} {/* DETAILS */} - + {renderDeetItem('id', p.proposalId)} {renderDeetItem('created', formatDateSeconds(p.dateCreated))} {renderDeetItem( @@ -630,10 +628,10 @@ class ProposalDetailNaked extends React.Component { formatDurationSeconds(p.deadlineDuration), )} {p.datePublished && - renderDeetItem( - '(deadline)', - formatDateSeconds(p.datePublished + p.deadlineDuration), - )} + renderDeetItem( + '(deadline)', + formatDateSeconds(p.datePublished + p.deadlineDuration), + )} {renderDeetItem('isFailed', JSON.stringify(p.isFailed))} {renderDeetItem('status', p.status)} {renderDeetItem('stage', p.stage)} @@ -662,14 +660,14 @@ class ProposalDetailNaked extends React.Component { >, )} {p.rfp && - renderDeetItem( - 'rfp', - {p.rfp.title}, - )} + renderDeetItem( + 'rfp', + {p.rfp.title}, + )} {/* TEAM */} - + {p.team.map(t => ( {t.displayName} @@ -783,6 +781,10 @@ class ProposalDetailNaked extends React.Component { await store.markMilestonePaid(pid, mid, this.state.paidTxId); message.success('Marked milestone paid.'); }; + + private handleSwitchFunder = async (checkValue: boolean) => { + store.switchProposalFunder(checkValue); + }; } const ProposalDetail = withRouter(view(ProposalDetailNaked)); diff --git a/admin/src/store.ts b/admin/src/store.ts index fc9a9818..489e3ae2 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -142,6 +142,11 @@ async function approveDiscussion( return data; } +async function switchProposalFunder(id: number, fundedByZomg: boolean) { + const { data } = await api.put(`/admin/proposals/${id}/adjust-funder`, {fundedByZomg}); + return data; +} + async function approveProposalKYC(id: number) { const { data } = await api.put(`/admin/proposals/${id}/approve-kyc`); return data; @@ -351,6 +356,7 @@ const app = store({ proposalDetailMarkingChangesAsResolved: false, proposalDetailAcceptingProposal: false, proposalDetailApprovingKyc: false, + proposalDetailSwitchingFunder: false, proposalDetailMarkingMilestonePaid: false, proposalDetailCanceling: false, proposalDetailUpdating: false, @@ -695,6 +701,24 @@ const app = store({ } }, + async switchProposalFunder(fundedByZomg: boolean) { + if (!app.proposalDetail) { + const m = 'store.acceptProposal(): Expected proposalDetail to be populated!'; + app.generalError.push(m); + console.error(m); + return; + } + app.proposalDetailSwitchingFunder = true; + try { + const { proposalId } = app.proposalDetail; + const res = await switchProposalFunder(proposalId, fundedByZomg); + app.updateProposalInStore(res); + } catch (e) { + handleApiError(e); + } + app.proposalDetailSwitchingFunder = false; + }, + async approveProposalKYC() { if (!app.proposalDetail) { const m = 'store.acceptProposal(): Expected proposalDetail to be populated!'; diff --git a/admin/src/types.ts b/admin/src/types.ts index 2aae7174..77669b33 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -124,6 +124,7 @@ export interface Proposal { changesRequestedDiscussion: boolean | null; changesRequestedDiscussionReason: string | null; kycApproved: null | boolean; + fundedByZomg: boolean; } export interface Comment { id: number; diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index b394062b..21545ec7 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -4,7 +4,7 @@ from functools import reduce from flask import Blueprint, request from marshmallow import fields, validate -from sqlalchemy import func, or_, text +from sqlalchemy import func, text import grant.utils.admin as admin import grant.utils.auth as auth @@ -25,7 +25,7 @@ from grant.proposal.models import ( admin_proposal_contributions_schema, ) from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema -from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema +from grant.user.models import User, admin_users_schema, admin_user_schema from grant.utils import pagination from grant.utils.enums import ( ProposalStatus, @@ -390,6 +390,22 @@ def approve_proposal_kyc(id): return proposal_schema.dump(proposal) +@blueprint.route('/proposals//adjust-funder', methods=['PUT']) +@body({ + "fundedByZomg": fields.Bool(required=True), +}) +@admin.admin_auth_required +def adjust_funder(id, funded_by_zomg): + proposal = Proposal.query.get(id) + if not proposal: + return {"message": "No proposal found."}, 404 + + proposal.funded_by_zomg = funded_by_zomg + db.session.add(proposal) + db.session.commit() + return proposal_schema.dump(proposal) + + @blueprint.route('/proposals//accept', methods=['PUT']) @body({ "isAccepted": fields.Bool(required=True), diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 59e3ff70..b49e2b94 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -1,8 +1,8 @@ import datetime import json -from typing import Optional from decimal import Decimal, ROUND_DOWN from functools import reduce +from typing import Optional from marshmallow import post_dump from sqlalchemy import func, or_, select, ForeignKey @@ -10,15 +10,14 @@ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import column_property from grant.comment.models import Comment -from grant.milestone.models import Milestone from grant.email.send import send_email from grant.extensions import ma, db +from grant.milestone.models import Milestone from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX from grant.task.jobs import ContributionExpired from grant.utils.enums import ( ProposalStatus, ProposalStage, - Category, ContributionStatus, ProposalArbiterStatus, MilestoneStage, @@ -332,7 +331,8 @@ class ProposalRevision(db.Model): if old_proposal.title != new_proposal.title: proposal_changes.append({"type": ProposalChange.PROPOSAL_EDIT_TITLE}) - milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, new_proposal.milestones) + milestone_changes = ProposalRevision.calculate_milestone_changes(old_proposal.milestones, + new_proposal.milestones) return proposal_changes + milestone_changes @@ -392,6 +392,7 @@ class Proposal(db.Model): date_published = db.Column(db.DateTime) reject_reason = db.Column(db.String()) kyc_approved = db.Column(db.Boolean(), nullable=True, default=False) + funded_by_zomg = db.Column(db.Boolean(), nullable=True, default=False) accepted_with_funding = db.Column(db.Boolean(), nullable=True) changes_requested_discussion = db.Column(db.Boolean(), nullable=True) @@ -422,21 +423,23 @@ class Proposal(db.Model): ) followers_count = column_property( select([func.count(proposal_follower.c.proposal_id)]) - .where(proposal_follower.c.proposal_id == id) - .correlate_except(proposal_follower) + .where(proposal_follower.c.proposal_id == id) + .correlate_except(proposal_follower) ) likes = db.relationship( "User", secondary=proposal_liker, back_populates="liked_proposals" ) likes_count = column_property( select([func.count(proposal_liker.c.proposal_id)]) - .where(proposal_liker.c.proposal_id == id) - .correlate_except(proposal_liker) + .where(proposal_liker.c.proposal_id == id) + .correlate_except(proposal_liker) ) live_draft_parent_id = db.Column(db.Integer, ForeignKey('proposal.id')) - live_draft = db.relationship("Proposal", uselist=False, backref=db.backref('live_draft_parent', remote_side=[id], uselist=False)) + live_draft = db.relationship("Proposal", uselist=False, + backref=db.backref('live_draft_parent', remote_side=[id], uselist=False)) - revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True, cascade="all, delete-orphan") + revisions = db.relationship(ProposalRevision, foreign_keys=[ProposalRevision.proposal_id], lazy=True, + cascade="all, delete-orphan") def __init__( self, @@ -527,7 +530,7 @@ class Proposal(db.Model): # Validate payout address if not is_z_address_valid(self.payout_address): raise ValidationException("Payout address is not a valid z address") - + # Validate tip jar address if self.tip_jar_address and not is_z_address_valid(self.tip_jar_address): raise ValidationException("Tip address is not a valid z address") @@ -535,7 +538,6 @@ class Proposal(db.Model): # Then run through regular validation Proposal.simple_validate(vars(self)) - def validate_milestone_days(self): for milestone in self.milestones: if milestone.immediate_payout: @@ -612,11 +614,11 @@ class Proposal(db.Model): self.rfp_opt_in = opt_in def create_contribution( - self, - amount, - user_id: int = None, - staking: bool = False, - private: bool = True, + self, + amount, + user_id: int = None, + staking: bool = False, + private: bool = True, ): contribution = ProposalContribution( proposal_id=self.id, @@ -923,8 +925,8 @@ class Proposal(db.Model): return False res = ( db.session.query(proposal_follower) - .filter_by(user_id=authed.id, proposal_id=self.id) - .count() + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() ) if res: return True @@ -939,8 +941,8 @@ class Proposal(db.Model): return False res = ( db.session.query(proposal_liker) - .filter_by(user_id=authed.id, proposal_id=self.id) - .count() + .filter_by(user_id=authed.id, proposal_id=self.id) + .count() ) if res: return True @@ -1099,7 +1101,8 @@ class ProposalSchema(ma.Schema): "changes_requested_discussion", "changes_requested_discussion_reason", "live_draft_id", - "kyc_approved" + "kyc_approved", + "funded_by_zomg" ) date_created = ma.Method("get_date_created") @@ -1109,6 +1112,7 @@ class ProposalSchema(ma.Schema): is_version_two = ma.Method("get_is_version_two") tip_jar_view_key = ma.Method("get_tip_jar_view_key") live_draft_id = ma.Method("get_live_draft_id") + funded_by_zomg = ma.Method("get_funded_by_zomg") updates = ma.Nested("ProposalUpdateSchema", many=True) team = ma.Nested("UserSchema", many=True) @@ -1118,6 +1122,14 @@ class ProposalSchema(ma.Schema): rfp = ma.Nested("RFPSchema", exclude=["accepted_proposals"]) arbiter = ma.Nested("ProposalArbiterSchema", exclude=["proposal"]) + def get_funded_by_zomg(self, obj): + if obj.funded_by_zomg is None: + return False + elif obj.funded_by_zomg is False: + return False + else: + return True + def get_proposal_id(self, obj): return obj.id diff --git a/backend/migrations/versions/91b16dc2fd74_.py b/backend/migrations/versions/91b16dc2fd74_.py new file mode 100644 index 00000000..7d44efd7 --- /dev/null +++ b/backend/migrations/versions/91b16dc2fd74_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 91b16dc2fd74 +Revises: d03c91f3038d +Create Date: 2021-02-01 17:00:23.721765 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '91b16dc2fd74' +down_revision = 'd03c91f3038d' +branch_labels = None +depends_on = None + + +def upgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.add_column('proposal', sa.Column('funded_by_zomg', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): +# ### commands auto generated by Alembic - please adjust! ### + op.drop_column('proposal', 'funded_by_zomg') + # ### end Alembic commands ### diff --git a/frontend/client/api/constants.ts b/frontend/client/api/constants.ts index 77d60521..19d88af9 100644 --- a/frontend/client/api/constants.ts +++ b/frontend/client/api/constants.ts @@ -86,11 +86,11 @@ export const STAGE_UI: { [key in PROPOSAL_FILTERS]: StageUI } = { color: '#8e44ad', }, ACCEPTED_WITH_FUNDING: { - label: 'Funded by ZF', + label: 'Funded', color: '#8e44ad', }, ACCEPTED_WITHOUT_FUNDING: { - label: 'Not Funded by ZF', + label: 'Not Funded', color: '#8e44ad', }, WIP: { diff --git a/frontend/client/components/Profile/ProfileProposal.tsx b/frontend/client/components/Profile/ProfileProposal.tsx index 853cbfe4..49947dd3 100644 --- a/frontend/client/components/Profile/ProfileProposal.tsx +++ b/frontend/client/components/Profile/ProfileProposal.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { UserProposal, STATUS } from 'types'; +import { STATUS, UserProposal } from 'types'; import './ProfileProposal.less'; import UserRow from 'components/UserRow'; import UnitDisplay from 'components/UnitDisplay'; @@ -23,7 +23,8 @@ export default class Profile extends React.Component { isVersionTwo, acceptedWithFunding, status, - changesRequestedDiscussionReason + changesRequestedDiscussionReason, + fundedByZomg, } = this.props.proposal; // pulled from `variables.less` @@ -31,18 +32,24 @@ export default class Profile extends React.Component { const secondaryColor = '#2D2A26'; const isOpenForDiscussion = status === STATUS.DISCUSSION; - const discussionColor = changesRequestedDiscussionReason ? 'red' : infoColor - const discussionTag = changesRequestedDiscussionReason ? 'Changes Requested' : 'Open for Public Review' + const discussionColor = changesRequestedDiscussionReason ? 'red' : infoColor; + const discussionTag = changesRequestedDiscussionReason + ? 'Changes Requested' + : 'Open for Public Review'; - let tagColor = infoColor - let tagMessage = 'Open for Contributions' + let tagColor = infoColor; + let tagMessage = 'Open for Contributions'; if (acceptedWithFunding) { - tagColor = secondaryColor - tagMessage = 'Funded by ZF' + tagColor = secondaryColor; + if (!fundedByZomg) { + tagMessage = 'Funded by ZF'; + } else { + tagMessage = 'Funded by ZOMG'; + } } else if (isOpenForDiscussion) { - tagColor = discussionColor - tagMessage = discussionTag + tagColor = discussionColor; + tagMessage = discussionTag; } return ( diff --git a/frontend/client/components/Proposal/CampaignBlock/index.tsx b/frontend/client/components/Proposal/CampaignBlock/index.tsx index 78c0b7f5..59bede65 100644 --- a/frontend/client/components/Proposal/CampaignBlock/index.tsx +++ b/frontend/client/components/Proposal/CampaignBlock/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import moment from 'moment'; -import { Icon, Popover, Tooltip, Alert } from 'antd'; +import { Alert, Icon, Popover, Tooltip } from 'antd'; import { Proposal, STATUS } from 'types'; import classnames from 'classnames'; import { connect } from 'react-redux'; @@ -12,6 +12,8 @@ import Loader from 'components/Loader'; import { PROPOSAL_STAGE } from 'api/constants'; import { formatUsd } from 'utils/formatters'; import ZFGrantsLogo from 'static/images/logo-name-light.svg'; +import ZomgLogo from 'static/images/zomg-logo.png'; + import './style.less'; interface OwnProps { @@ -134,7 +136,11 @@ export class ProposalCampaignBlock extends React.Component { isAcceptedWithFunding && ( Funded through - + {proposal.fundedByZomg ? ( + + ) : ( + + )} )} diff --git a/frontend/client/components/Proposals/ProposalCard/index.tsx b/frontend/client/components/Proposals/ProposalCard/index.tsx index c965b021..ce427158 100644 --- a/frontend/client/components/Proposals/ProposalCard/index.tsx +++ b/frontend/client/components/Proposals/ProposalCard/index.tsx @@ -29,6 +29,7 @@ export class ProposalCard extends React.Component { percentFunded, acceptedWithFunding, status, + fundedByZomg, } = this.props; // pulled from `variables.less` @@ -46,7 +47,11 @@ export class ProposalCard extends React.Component { if (isVersionTwo && status === STATUS.LIVE) { if (acceptedWithFunding) { tagColor = secondaryColor; - tagMessage = 'Funded by ZF'; + if (!fundedByZomg) { + tagMessage = 'Funded by ZF'; + } else { + tagMessage = 'Funded by ZOMG'; + } } else { tagColor = infoColor; tagMessage = 'Not Funded'; diff --git a/frontend/client/static/images/zomg-logo.png b/frontend/client/static/images/zomg-logo.png new file mode 100644 index 00000000..512d5513 Binary files /dev/null and b/frontend/client/static/images/zomg-logo.png differ diff --git a/frontend/client/static/images/zomg-logo.svg b/frontend/client/static/images/zomg-logo.svg new file mode 100644 index 00000000..22c5d9b7 --- /dev/null +++ b/frontend/client/static/images/zomg-logo.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + zomg + + + + + + + diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index ca81af18..829c9054 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -81,6 +81,7 @@ export interface Proposal extends Omit { liveDraftId: string | null; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived + fundedByZomg: boolean; } export interface TeamInviteWithProposal extends TeamInvite {
@@ -390,10 +377,10 @@ class ProposalDetailNaked extends React.Component { with payouts.
An arbiter is required to review milestone payout requests.
@@ -469,9 +456,9 @@ class ProposalDetailNaked extends React.Component { return ( @@ -487,9 +474,9 @@ class ProposalDetailNaked extends React.Component {
{p.payoutAddress}
{JSON.stringify(p, null, 4)}