Working invitations + deletions.

This commit is contained in:
Will O'Beirne 2018-11-16 11:16:52 -05:00
parent c378c1d1bb
commit a3cfbda3e2
No known key found for this signature in database
GPG Key ID: 44C190DB5DEAF9F6
11 changed files with 163 additions and 67 deletions

View File

@ -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': 'Youve been invited!',
'preview': 'Youve 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,
)

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
U invited

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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