diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index 4147c869..9721da4f 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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, ) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 5ab5a9a1..c9e8db11 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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") diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index a68653f1..db3952bd 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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("//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("//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("//invite/", 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 \ No newline at end of file diff --git a/backend/grant/templates/emails/team_invite.html b/backend/grant/templates/emails/team_invite.html new file mode 100644 index 00000000..99cb21e1 --- /dev/null +++ b/backend/grant/templates/emails/team_invite.html @@ -0,0 +1,19 @@ +

+ U invited +

+ + + + + +
+ + + + +
+ + See invitation + +
+
\ No newline at end of file diff --git a/backend/grant/templates/emails/team_invite.txt b/backend/grant/templates/emails/team_invite.txt new file mode 100644 index 00000000..20e8bca9 --- /dev/null +++ b/backend/grant/templates/emails/team_invite.txt @@ -0,0 +1 @@ +U invited \ No newline at end of file diff --git a/backend/grant/utils/misc.py b/backend/grant/utils/misc.py index 6abe899b..48488063 100644 --- a/backend/grant/utils/misc.py +++ b/backend/grant/utils/misc.py @@ -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)) diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 48718e40..60f20f3a 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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}`); +} diff --git a/frontend/client/components/CreateFlow/Team.tsx b/frontend/client/components/CreateFlow/Team.tsx index 7e6cca67..7690fcb1 100644 --- a/frontend/client/components/CreateFlow/Team.tsx +++ b/frontend/client/components/CreateFlow/Team.tsx @@ -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; updateForm(form: Partial): void; } @@ -36,8 +38,8 @@ const DEFAULT_STATE: State = { socialAccounts: {}, }, ], - teamInvites: [], - invite: '', + invites: [], + address: '', }; class CreateFlowTeam extends React.Component { @@ -65,32 +67,27 @@ class CreateFlowTeam extends React.Component { } 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 (
{team.map((user, idx) => ( - + ))} - {!!teamInvites.length && ( + {!!invites.length && (

Pending invitations

- {teamInvites.map((ti, idx) => ( -
-
{ti}
+ {invites.map(inv => ( +
+
{inv.address}
this.removeInvitation(idx)} + onConfirm={() => this.removeInvitation(inv.id)} >
- {index !== 0 && ( - - )}
); } - - private removeMember = () => { - this.props.onRemove(this.props.index); - }; } diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index bdb51b0f..e94921a0 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -190,6 +190,7 @@ class CreateFlow extends React.Component {