Working invitations + deletions.
This commit is contained in:
parent
c378c1d1bb
commit
a3cfbda3e2
|
@ -9,34 +9,46 @@ default_template_args = {
|
|||
'unsubscribe_url': 'https://grant.io/unsubscribe',
|
||||
}
|
||||
|
||||
email_template_args = {
|
||||
'signup': {
|
||||
def signup_info(email_args):
|
||||
return {
|
||||
'subject': 'Confirm your email on Grant.io',
|
||||
'title': 'Welcome to Grant.io!',
|
||||
'preview': 'Welcome to Grant.io, we just need to confirm your email address.',
|
||||
},
|
||||
}
|
||||
|
||||
def team_invite_info(email_args):
|
||||
return {
|
||||
'subject': '{} has invited you to a project'.format(email_args.inviter.display_name),
|
||||
'title': 'You’ve been invited!',
|
||||
'preview': 'You’ve been invited to the "{}" project team'.format(email_args.proposal.title)
|
||||
}
|
||||
|
||||
get_info_lookup = {
|
||||
'signup': signup_info,
|
||||
'team_invite': team_invite_info
|
||||
}
|
||||
|
||||
|
||||
def send_email(to, type, email_args):
|
||||
try:
|
||||
info = get_info_lookup[type](email_args)
|
||||
body_text = render_template('emails/%s.txt' % (type), args=email_args)
|
||||
body_html = render_template('emails/%s.html' % (type), args=email_args)
|
||||
|
||||
html = render_template('emails/template.html', args={
|
||||
**default_template_args,
|
||||
**email_template_args[type],
|
||||
**info,
|
||||
'body': Markup(body_html),
|
||||
})
|
||||
text = render_template('emails/template.txt', args={
|
||||
**default_template_args,
|
||||
**email_template_args[type],
|
||||
**info,
|
||||
'body': body_text,
|
||||
})
|
||||
|
||||
res = mail.send_email(
|
||||
to_email=to,
|
||||
subject=email_template_args[type]['subject'],
|
||||
subject=info['subject'],
|
||||
text=text,
|
||||
html=html,
|
||||
)
|
||||
|
|
|
@ -203,7 +203,8 @@ class ProposalSchema(ma.Schema):
|
|||
"trustees",
|
||||
"payout_address",
|
||||
"deadline_duration",
|
||||
"vote_duration"
|
||||
"vote_duration",
|
||||
"invites"
|
||||
)
|
||||
|
||||
date_created = ma.Method("get_date_created")
|
||||
|
|
|
@ -8,9 +8,13 @@ from sqlalchemy.exc import IntegrityError
|
|||
from grant.comment.models import Comment, comment_schema
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.user.models import User, SocialMedia, Avatar
|
||||
from grant.email.send import send_email
|
||||
from grant.utils.auth import requires_sm, requires_team_member_auth
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, proposal_update_schema, proposal_team, db
|
||||
from grant.utils.misc import is_email
|
||||
from .models import Proposal, proposals_schema, proposal_schema, ProposalUpdate, \
|
||||
proposal_update_schema, proposal_team, ProposalTeamInvite, proposal_team_invite_schema, db
|
||||
|
||||
|
||||
blueprint = Blueprint("proposal", __name__, url_prefix="/api/v1/proposals")
|
||||
|
||||
|
@ -197,7 +201,6 @@ def get_proposal_update(proposal_id, update_id):
|
|||
|
||||
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
|
||||
@requires_team_member_auth
|
||||
@requires_sm
|
||||
@endpoint.api(
|
||||
parameter('title', type=str, required=True),
|
||||
parameter('content', type=str, required=True)
|
||||
|
@ -213,3 +216,48 @@ def post_proposal_update(proposal_id, title, content):
|
|||
|
||||
dumped_update = proposal_update_schema.dump(update)
|
||||
return dumped_update, 201
|
||||
|
||||
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
||||
@requires_team_member_auth
|
||||
@endpoint.api(
|
||||
parameter('address', type=str, required=True)
|
||||
)
|
||||
def post_proposal_team_invite(proposal_id, address):
|
||||
invite = ProposalTeamInvite(
|
||||
proposal_id=proposal_id,
|
||||
address=address
|
||||
)
|
||||
db.session.add(invite)
|
||||
db.session.commit()
|
||||
|
||||
# Send email
|
||||
# TODO: Move this to some background task / after request action
|
||||
email = address
|
||||
user = User.get_by_identifier(email_address=address, account_address=address)
|
||||
if user:
|
||||
email = user.email_address
|
||||
if is_email(email):
|
||||
send_email(email, 'team_invite', {
|
||||
'user': user,
|
||||
'inviter': g.current_user,
|
||||
'proposal': g.current_proposal
|
||||
})
|
||||
|
||||
return proposal_team_invite_schema.dump(invite), 201
|
||||
|
||||
@blueprint.route("/<proposal_id>/invite/<id_or_address>", methods=["DELETE"])
|
||||
@requires_team_member_auth
|
||||
@endpoint.api()
|
||||
def delete_proposal_team_invite(proposal_id, id_or_address):
|
||||
invite = ProposalTeamInvite.query.filter(
|
||||
(ProposalTeamInvite.id == id_or_address) |
|
||||
(ProposalTeamInvite.address == id_or_address)
|
||||
).first()
|
||||
if not invite:
|
||||
return {"message": "No invite found given {}".format(id_or_address)}, 404
|
||||
if invite.accepted:
|
||||
return {"message": "Cannot delete an invite that has been accepted"}, 403
|
||||
|
||||
db.session.delete(invite)
|
||||
db.session.commit()
|
||||
return None, 202
|
|
@ -0,0 +1,19 @@
|
|||
<p style="margin: 0;">
|
||||
U invited
|
||||
</p>
|
||||
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" align="center" style="padding: 40px 30px 40px 30px;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="#530EEC">
|
||||
<a href="{{ args.confirm_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 #530EEC; display: inline-block;">
|
||||
See invitation
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
|
@ -0,0 +1 @@
|
|||
U invited
|
|
@ -2,6 +2,7 @@ import datetime
|
|||
import time
|
||||
import random
|
||||
import string
|
||||
import re
|
||||
from grant.settings import SITE_URL
|
||||
|
||||
epoch = datetime.datetime.utcfromtimestamp(0)
|
||||
|
@ -26,3 +27,6 @@ def gen_random_code(length=32):
|
|||
|
||||
def make_url(path: str):
|
||||
return f'{SITE_URL}{path}'
|
||||
|
||||
def is_email(email: str):
|
||||
return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import axios from './axios';
|
||||
import { Proposal, ProposalDraft, TeamMember, Update } from 'types';
|
||||
import { Proposal, ProposalDraft, TeamMember, Update, TeamInvite } from 'types';
|
||||
import {
|
||||
formatTeamMemberForPost,
|
||||
formatTeamMemberFromGet,
|
||||
|
@ -129,3 +129,17 @@ export function putProposalPublish(
|
|||
contractAddress,
|
||||
});
|
||||
}
|
||||
|
||||
export function postProposalInvite(
|
||||
proposalId: number,
|
||||
address: string,
|
||||
): Promise<{ data: TeamInvite }> {
|
||||
return axios.post(`/api/v1/proposals/${proposalId}/invite`, { address });
|
||||
}
|
||||
|
||||
export function deleteProposalInvite(
|
||||
proposalId: number,
|
||||
inviteIdOrAddress: number | string,
|
||||
): Promise<{ data: TeamInvite }> {
|
||||
return axios.delete(`/api/v1/proposals/${proposalId}/invite/${inviteIdOrAddress}`);
|
||||
}
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Icon, Form, Input, Button, Popconfirm } from 'antd';
|
||||
import { TeamMember, ProposalDraft } from 'types';
|
||||
import { Icon, Form, Input, Button, Popconfirm, message } from 'antd';
|
||||
import { TeamMember, TeamInvite, ProposalDraft } from 'types';
|
||||
import TeamMemberComponent from './TeamMember';
|
||||
import { postProposalInvite, deleteProposalInvite } from 'api/api';
|
||||
import { isValidEthAddress, isValidEmail } from 'utils/validators';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './Team.less';
|
||||
|
||||
interface State {
|
||||
team: TeamMember[];
|
||||
teamInvites: string[];
|
||||
invite: string;
|
||||
invites: TeamInvite[];
|
||||
address: string;
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
|
@ -18,6 +19,7 @@ interface StateProps {
|
|||
}
|
||||
|
||||
interface OwnProps {
|
||||
proposalId: number;
|
||||
initialState?: Partial<State>;
|
||||
updateForm(form: Partial<ProposalDraft>): void;
|
||||
}
|
||||
|
@ -36,8 +38,8 @@ const DEFAULT_STATE: State = {
|
|||
socialAccounts: {},
|
||||
},
|
||||
],
|
||||
teamInvites: [],
|
||||
invite: '',
|
||||
invites: [],
|
||||
address: '',
|
||||
};
|
||||
|
||||
class CreateFlowTeam extends React.Component<Props, State> {
|
||||
|
@ -65,32 +67,27 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { team, teamInvites, invite } = this.state;
|
||||
const { team, invites, address } = this.state;
|
||||
const inviteError =
|
||||
invite && !isValidEmail(invite) && !isValidEthAddress(invite)
|
||||
address && !isValidEmail(address) && !isValidEthAddress(address)
|
||||
? 'That doesn’t look like an email address or ETH address'
|
||||
: undefined;
|
||||
const inviteDisabled = !!inviteError || !invite;
|
||||
const inviteDisabled = !!inviteError || !address;
|
||||
|
||||
return (
|
||||
<div className="TeamForm">
|
||||
{team.map((user, idx) => (
|
||||
<TeamMemberComponent
|
||||
key={idx}
|
||||
index={idx}
|
||||
user={user}
|
||||
onRemove={this.removeMember}
|
||||
/>
|
||||
<TeamMemberComponent key={idx} index={idx} user={user} />
|
||||
))}
|
||||
{!!teamInvites.length && (
|
||||
{!!invites.length && (
|
||||
<div className="TeamForm-pending">
|
||||
<h3 className="TeamForm-pending-title">Pending invitations</h3>
|
||||
{teamInvites.map((ti, idx) => (
|
||||
<div key={ti} className="TeamForm-pending-invite">
|
||||
<div className="TeamForm-pending-invite-name">{ti}</div>
|
||||
{invites.map(inv => (
|
||||
<div key={inv.id} className="TeamForm-pending-invite">
|
||||
<div className="TeamForm-pending-invite-name">{inv.address}</div>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
onConfirm={() => this.removeInvitation(idx)}
|
||||
onConfirm={() => this.removeInvitation(inv.id)}
|
||||
>
|
||||
<button className="TeamForm-pending-invite-delete">
|
||||
<Icon type="delete" />
|
||||
|
@ -116,8 +113,8 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
className="TeamForm-add-form-field-input"
|
||||
placeholder="Email address or ETH address"
|
||||
size="large"
|
||||
value={invite}
|
||||
onChange={this.handleChangeInvite}
|
||||
value={address}
|
||||
onChange={this.handleChangeInviteAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
|
@ -137,36 +134,38 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private handleChangeInvite = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ invite: ev.currentTarget.value });
|
||||
private handleChangeInviteAddress = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ address: ev.currentTarget.value });
|
||||
};
|
||||
|
||||
private handleAddSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
|
||||
ev.preventDefault();
|
||||
const teamInvites = [...this.state.teamInvites, this.state.invite];
|
||||
this.setState({
|
||||
teamInvites,
|
||||
invite: '',
|
||||
});
|
||||
this.props.updateForm({ teamInvites });
|
||||
postProposalInvite(this.props.proposalId, this.state.address)
|
||||
.then(res => {
|
||||
const invites = [...this.state.invites, res.data];
|
||||
this.setState({
|
||||
invites,
|
||||
address: '',
|
||||
});
|
||||
this.props.updateForm({ invites });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Failed to send invite', err);
|
||||
message.error('Failed to send invite', 3);
|
||||
});
|
||||
};
|
||||
|
||||
private removeMember = (index: number) => {
|
||||
const team = [
|
||||
...this.state.team.slice(0, index),
|
||||
...this.state.team.slice(index + 1),
|
||||
];
|
||||
this.setState({ team });
|
||||
this.props.updateForm({ team });
|
||||
};
|
||||
|
||||
private removeInvitation = (index: number) => {
|
||||
const teamInvites = [
|
||||
...this.state.teamInvites.slice(0, index),
|
||||
...this.state.teamInvites.slice(index + 1),
|
||||
];
|
||||
this.setState({ teamInvites });
|
||||
this.props.updateForm({ teamInvites });
|
||||
private removeInvitation = (invId: number) => {
|
||||
deleteProposalInvite(this.props.proposalId, invId)
|
||||
.then(() => {
|
||||
const invites = this.state.invites.filter(inv => inv.id !== invId);
|
||||
this.setState({ invites });
|
||||
this.props.updateForm({ invites });
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Failed to remove invite', err);
|
||||
message.error('Failed to remove invite', 3);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ import './TeamMember.less';
|
|||
interface Props {
|
||||
index: number;
|
||||
user: TeamMember;
|
||||
onRemove(index: number): void;
|
||||
}
|
||||
|
||||
export default class CreateFlowTeamMember extends React.PureComponent<Props> {
|
||||
|
@ -45,17 +44,8 @@ export default class CreateFlowTeamMember extends React.PureComponent<Props> {
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{index !== 0 && (
|
||||
<button className="TeamMember-info-remove" onClick={this.removeMember}>
|
||||
<Icon type="close-circle" theme="filled" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private removeMember = () => {
|
||||
this.props.onRemove(this.props.index);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -190,6 +190,7 @@ class CreateFlow extends React.Component<Props, State> {
|
|||
</div>
|
||||
<div className="CreateFlow-content">
|
||||
<StepComponent
|
||||
proposalId={this.props.form!.proposalId}
|
||||
initialState={this.props.form}
|
||||
updateForm={this.debouncedUpdateForm}
|
||||
setStep={this.setStep}
|
||||
|
|
|
@ -9,6 +9,13 @@ import {
|
|||
Comment,
|
||||
} from 'types';
|
||||
|
||||
export interface TeamInvite {
|
||||
id: number;
|
||||
dateCreated: number;
|
||||
address: string;
|
||||
accepted: boolean | null;
|
||||
}
|
||||
|
||||
export interface Contributor {
|
||||
address: string;
|
||||
contributionAmount: Wei;
|
||||
|
@ -51,7 +58,7 @@ export interface ProposalDraft {
|
|||
voteDuration: number;
|
||||
milestones: CreateMilestone[];
|
||||
team: TeamMember[];
|
||||
teamInvites: string[];
|
||||
invites: TeamInvite[];
|
||||
}
|
||||
|
||||
export interface Proposal {
|
||||
|
|
Loading…
Reference in New Issue