Proposal Subscription (#31)

* init proposal subscribe be and fe

* add subscription email templates

* wire up subscription emails

* email subscribers on proposal milestone, update, cancel

* disallow subscriptions if email not verified

* update spelling, titles

* disallow proposal subscribe if user is team member

* hide subscribe if not signed in, is team member, canceled

* port follow from grant-base

* remove subscribed

* convert subscribed to follower

* backend - update tests

* frontend - fix typings

* finish follower port

* update comment

* fix email button display issues

* remove loading on AuthButton to prevent two spinners
This commit is contained in:
Danny Skubak 2019-10-23 17:34:10 -04:00 committed by Daniel Ternyak
parent 58eb8f2455
commit 5799ffab19
22 changed files with 484 additions and 39 deletions

View File

@ -145,4 +145,15 @@ export default [
title: 'Admin Payout',
description: 'Sent when milestone payout has been approved',
},
{
id: 'followed_proposal_milestone',
title: 'Followed Proposal Milestone',
description:
'Sent to followers of a proposal when one of its milestones has been approved',
},
{
id: 'followed_proposal_update',
title: 'Followed Proposal Update',
description: 'Sent to followers of a proposal when it has a new update',
},
] as Email[];

View File

@ -178,4 +178,13 @@ example_email_args = {
'proposal': proposal,
'proposal_url': 'https://grants-admin.zfnd.org/proposals/999',
},
'followed_proposal_milestone': {
"proposal": proposal,
"milestone": milestone,
"proposal_url": "http://someproposal.com",
},
'followed_proposal_update': {
"proposal": proposal,
"proposal_url": "http://someproposal.com",
},
}

View File

@ -415,6 +415,13 @@ def paid_milestone_payout_request(id, mid, tx_id):
'tx_explorer_url': make_explore_url(tx_id),
'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'),
})
# email FOLLOWERS that milestone was accepted
proposal.send_follower_email(
"followed_proposal_milestone",
email_args={"milestone": ms},
url_suffix="?tab=milestones",
)
return proposal_schema.dump(proposal), 200
return {"message": "No milestone matching id"}, 404

View File

@ -307,6 +307,27 @@ def admin_payout(email_args):
}
def followed_proposal_milestone(email_args):
p = email_args["proposal"]
ms = email_args["milestone"]
return {
"subject": f"Milestone accepted for {p.title}",
"title": f"Milestone Accepted",
"preview": f"Followed proposal {p.title} has passed a milestone",
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
}
def followed_proposal_update(email_args):
p = email_args["proposal"]
return {
"subject": f"Proposal update for {p.title}",
"title": f"Proposal Update",
"preview": f"Followed proposal {p.title} has an update",
"subscription": EmailSubscription.FOLLOWED_PROPOSAL,
}
get_info_lookup = {
'signup': signup_info,
'team_invite': team_invite_info,
@ -335,7 +356,9 @@ get_info_lookup = {
'milestone_paid': milestone_paid,
'admin_approval': admin_approval,
'admin_arbiter': admin_arbiter,
'admin_payout': admin_payout
'admin_payout': admin_payout,
'followed_proposal_milestone': followed_proposal_milestone,
'followed_proposal_update': followed_proposal_update
}

View File

@ -65,6 +65,10 @@ class EmailSubscription(Enum):
'bit': 14,
'key': 'admin_payout'
}
FOLLOWED_PROPOSAL = {
'bit': 15,
'key': 'followed_proposal'
}
def is_email_sub_key(k: str):

View File

@ -2,12 +2,11 @@ import datetime
from decimal import Decimal, ROUND_DOWN
from functools import reduce
from flask import current_app
from marshmallow import post_dump
from sqlalchemy import func, or_
from sqlalchemy import func, or_, select
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import column_property
from flask import current_app
from grant.comment.models import Comment
from grant.email.send import send_email
from grant.extensions import ma, db
@ -32,6 +31,12 @@ proposal_team = db.Table(
db.Column('proposal_id', db.Integer, db.ForeignKey('proposal.id'))
)
proposal_follower = db.Table(
"proposal_follower",
db.Model.metadata,
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
db.Column("proposal_id", db.Integer, db.ForeignKey("proposal.id")),
)
class ProposalTeamInvite(db.Model):
__tablename__ = "proposal_team_invite"
@ -250,6 +255,14 @@ class Proposal(db.Model):
order_by="asc(Milestone.index)", lazy=True, cascade="all, delete-orphan")
invites = db.relationship(ProposalTeamInvite, backref="proposal", lazy=True, cascade="all, delete-orphan")
arbiter = db.relationship(ProposalArbiter, uselist=False, back_populates="proposal", cascade="all, delete-orphan")
followers = db.relationship(
"User", secondary=proposal_follower, back_populates="followed_proposals"
)
followers_count = column_property(
select([func.count(proposal_follower.c.proposal_id)])
.where(proposal_follower.c.proposal_id == id)
.correlate_except(proposal_follower)
)
def __init__(
self,
@ -572,6 +585,26 @@ class Proposal(db.Model):
'account_settings_url': make_url('/profile/settings?tab=account')
})
def follow(self, user, is_follow):
if is_follow:
self.followers.append(user)
else:
self.followers.remove(user)
db.session.flush()
def send_follower_email(self, type: str, email_args={}, url_suffix=""):
for u in self.followers:
send_email(
u.email_address,
type,
{
"user": u,
"proposal": self,
"proposal_url": make_url(f"/proposals/{self.id}{url_suffix}"),
**email_args,
},
)
@hybrid_property
def contributed(self):
contributions = ProposalContribution.query \
@ -639,6 +672,22 @@ class Proposal(db.Model):
d = {c.user.id: c.user for c in self.contributions if c.user and c.status == ContributionStatus.CONFIRMED}
return d.values()
@hybrid_property
def authed_follows(self):
from grant.utils.auth import get_authed_user
authed = get_authed_user()
if not authed:
return False
res = (
db.session.query(proposal_follower)
.filter_by(user_id=authed.id, proposal_id=self.id)
.count()
)
if res:
return True
return False
class ProposalSchema(ma.Schema):
class Meta:
@ -674,7 +723,9 @@ class ProposalSchema(ma.Schema):
"rfp_opt_in",
"arbiter",
"accepted_with_funding",
"is_version_two"
"is_version_two",
"authed_follows",
"followers_count"
)
date_created = ma.Method("get_date_created")
@ -722,7 +773,8 @@ user_fields = [
"date_published",
"reject_reason",
"team",
"is_version_two"
"is_version_two",
"authed_follows"
]
user_proposal_schema = ProposalSchema(only=user_fields)
user_proposals_schema = ProposalSchema(many=True, only=user_fields)

View File

@ -371,6 +371,11 @@ def post_proposal_update(proposal_id, title, content):
'update_url': make_url(f'/proposals/{proposal_id}?tab=updates&update={update.id}'),
})
# Send email to all followers
g.current_proposal.send_follower_email(
"followed_proposal_update", url_suffix="?tab=updates"
)
dumped_update = proposal_update_schema.dump(update)
return dumped_update, 201
@ -663,3 +668,19 @@ def reject_milestone_payout_request(proposal_id, milestone_id, reason):
return proposal_schema.dump(g.current_proposal), 200
return {"message": "No milestone matching id"}, 404
@blueprint.route("/<proposal_id>/follow", methods=["PUT"])
@requires_auth
@body({"isFollow": fields.Bool(required=True)})
def follow_proposal(proposal_id, is_follow):
user = g.current_user
# Make sure proposal exists
proposal = Proposal.query.filter_by(id=proposal_id).first()
if not proposal:
return {"message": "No proposal matching id"}, 404
proposal.follow(user, is_follow)
db.session.commit()
return {"message": "ok"}, 200

View File

@ -0,0 +1,31 @@
<p style="margin: 0;">
Your followed proposal {{ args.proposal.title }} has had its
{{ args.milestone.title }}
milestone accepted!
</p>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td
align="center"
bgcolor="{{ UI.PRIMARY }}"
style="border-radius: 3px;"
>
<a
href="{{ args.proposal_url }}"
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 {{
UI.PRIMARY
}}; display: inline-block;"
target="_blank"
>
Check it out
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted!
Check it out: {{ args.proposal_url }}

View File

@ -0,0 +1,29 @@
<p style="margin: 0;">
Your followed proposal {{ args.proposal.title }} has an update!
</p>
<table border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="center" bgcolor="#ffffff" style="padding: 40px 30px 40px 30px;">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td
align="center"
bgcolor="{{ UI.PRIMARY }}"
style="border-radius: 3px;"
>
<a
href="{{ args.proposal_url }}"
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 {{
UI.PRIMARY
}}; display: inline-block;"
target="_blank"
>
Check it out
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
Your followed proposal {{ args.proposal.title }} has an update!
Check it out: {{ args.proposal_url }}

View File

@ -133,6 +133,9 @@ class User(db.Model, UserMixin):
roles = db.relationship('Role', secondary='roles_users',
backref=db.backref('users', lazy='dynamic'))
arbiter_proposals = db.relationship("ProposalArbiter", lazy=True, back_populates="user")
followed_proposals = db.relationship(
"Proposal", secondary="proposal_follower", back_populates="followers"
)
def __init__(
self,

View File

@ -234,3 +234,51 @@ class TestProposalAPI(BaseProposalCreatorConfig):
for each_proposal in resp.json['items']:
for team_member in each_proposal["team"]:
self.assertIsNone(team_member.get('email_address'))
def test_follow_proposal(self):
# not logged in
resp = self.app.put(
f"/api/v1/proposals/{self.proposal.id}/follow",
data=json.dumps({"isFollow": True}),
content_type="application/json",
)
self.assert401(resp)
# logged in
self.login_default_user()
self.proposal.status = ProposalStatus.LIVE
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}")
self.assert200(resp)
self.assertEqual(resp.json["authedFollows"], False)
# follow
resp = self.app.put(
f"/api/v1/proposals/{self.proposal.id}/follow",
data=json.dumps({"isFollow": True}),
content_type="application/json",
)
self.assert200(resp)
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}")
self.assert200(resp)
self.assertEqual(resp.json["authedFollows"], True)
self.assertEqual(self.proposal.followers[0].id, self.user.id)
self.assertEqual(self.user.followed_proposals[0].id, self.proposal.id)
# un-follow
resp = self.app.put(
f"/api/v1/proposals/{self.proposal.id}/follow",
data=json.dumps({"isFollow": False}),
content_type="application/json",
)
self.assert200(resp)
resp = self.app.get(f"/api/v1/proposals/{self.proposal.id}")
self.assert200(resp)
self.assertEqual(resp.json["authedFollows"], False)
self.assertEqual(len(self.proposal.followers), 0)
self.assertEqual(len(self.user.followed_proposals), 0)

View File

@ -42,6 +42,10 @@ export function getProposal(proposalId: number | string): Promise<{ data: Propos
});
}
export function followProposal(proposalId: number, isFollow: boolean) {
return axios.put(`/api/v1/proposals/${proposalId}/follow`, { isFollow });
}
export function getProposalComments(proposalId: number | string, params: PageParams) {
return axios.get(`/api/v1/proposals/${proposalId}/comments`, { params });
}

View File

@ -0,0 +1,67 @@
import React from 'react';
import { connect } from 'react-redux';
import { Redirect, RouteProps } from 'react-router';
import { Button } from 'antd';
import { AppState } from 'store/reducers';
import { authActions } from 'modules/auth';
import { NativeButtonProps } from 'antd/lib/button/button';
import { withRouter } from 'react-router-dom';
import { compose } from 'recompose';
type OwnProps = NativeButtonProps;
interface StateProps {
user: AppState['auth']['user'];
}
interface DispatchProps {
setAuthForwardLocation: typeof authActions['setAuthForwardLocation'];
}
type Props = OwnProps & RouteProps & StateProps & DispatchProps;
const STATE = {
sendToAuth: false,
};
type State = typeof STATE;
class AuthButton extends React.Component<Props, State> {
state: State = { ...STATE };
public render() {
const { location, children, loading } = this.props;
if (this.state.sendToAuth) {
return <Redirect to={{ ...location, pathname: '/profile' }} />;
}
return (
<Button loading={loading} onClick={this.handleClick}>
{children}
</Button>
);
}
private handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!this.props.onClick) {
return;
}
if (this.props.user) {
this.props.onClick(e);
} else {
const { location, setAuthForwardLocation } = this.props;
setAuthForwardLocation(location);
setTimeout(() => {
this.setState({ sendToAuth: true });
}, 200);
}
};
}
const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
(state: AppState) => ({
user: state.auth.user,
}),
{ setAuthForwardLocation: authActions.setAuthForwardLocation },
);
export default compose<Props, OwnProps>(
withRouter,
withConnect,
)(AuthButton);

View File

@ -0,0 +1,24 @@
@import '~styles/variables.less';
@collapse-width: 800px;
.Follow {
white-space: nowrap;
.ant-btn:focus,
.ant-btn:active {
border-color: inherit;
outline-color: inherit;
color: inherit;
}
&-label {
@media (max-width: @collapse-width) {
display: none !important;
}
}
&-count {
color: @text-color !important;
}
}

View File

@ -0,0 +1,76 @@
import React from 'react';
import { connect } from 'react-redux';
import { Icon, Button, Input, message } from 'antd';
import { AppState } from 'store/reducers';
import { proposalActions } from 'modules/proposals';
import { ProposalDetail } from 'modules/proposals/reducers';
import { followProposal } from 'api/api';
import AuthButton from 'components/AuthButton';
import './index.less';
interface OwnProps {
proposal: ProposalDetail;
}
interface StateProps {
authUser: AppState['auth']['user'];
}
interface DispatchProps {
fetchProposal: typeof proposalActions['fetchProposal'];
}
type Props = OwnProps & StateProps & DispatchProps;
const STATE = {
loading: false,
};
type State = typeof STATE;
class Follow extends React.Component<Props, State> {
state: State = { ...STATE };
render() {
const { authedFollows, followersCount } = this.props.proposal;
const { loading } = this.state;
return (
<Input.Group className="Follow" compact>
<AuthButton onClick={this.handleFollow}>
<Icon
theme={authedFollows ? 'filled' : 'outlined'}
type={loading ? 'loading' : 'star'}
/>
<span className="Follow-label">{authedFollows ? ' Unfollow' : ' Follow'}</span>
</AuthButton>
<Button className="Follow-count" disabled>
<span>{followersCount}</span>
</Button>
</Input.Group>
);
}
private handleFollow = async () => {
const { proposalId, authedFollows } = this.props.proposal;
this.setState({ loading: true });
try {
await followProposal(proposalId, !authedFollows);
await this.props.fetchProposal(proposalId);
message.success(<>Proposal {authedFollows ? 'unfollowed' : 'followed'}</>);
} catch (error) {
// tslint:disable:no-console
console.error('Follow.handleFollow - unable to change follow state', error);
message.error('Unable to follow proposal');
}
this.setState({ loading: false });
};
}
const withConnect = connect<StateProps, DispatchProps, OwnProps, AppState>(
state => ({
authUser: state.auth.user,
}),
{
fetchProposal: proposalActions.fetchProposal,
},
);
export default withConnect(Follow);

View File

@ -65,23 +65,43 @@
}
&-title {
font-size: 2rem;
line-height: 3rem;
margin-bottom: 0.75rem;
margin-left: 0.5rem;
@media (min-width: @collapse-width) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (max-width: @collapse-width) {
font-size: 1.8rem;
}
display: flex;
@media (max-width: @single-col-width) {
font-size: 1.6rem;
flex-direction: column-reverse;
margin-top: -1rem;
}
h1 {
font-size: 2rem;
line-height: 3rem;
margin-bottom: 0.75rem;
margin-left: 0.5rem;
flex-grow: 1;
@media (min-width: @collapse-width) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (max-width: @collapse-width) {
font-size: 1.8rem;
}
@media (max-width: @single-col-width) {
font-size: 1.6rem;
}
}
&-menu {
display: flex;
align-items: center;
height: 3rem;
& > * + * {
margin-left: 0.5rem;
}
}
}

View File

@ -25,6 +25,7 @@ import CancelModal from './CancelModal';
import classnames from 'classnames';
import { withRouter } from 'react-router';
import SocialShare from 'components/SocialShare';
import Follow from 'components/Follow';
import './index.less';
interface OwnProps {
@ -184,9 +185,27 @@ export class ProposalDetail extends React.Component<Props, State> {
</div>
)}
<div className="Proposal-top-main">
<h1 className="Proposal-top-main-title">
{proposal ? proposal.title : <span>&nbsp;</span>}
</h1>
<div className="Proposal-top-main-title">
<h1>{proposal ? proposal.title : <span>&nbsp;</span>}</h1>
{isLive && (
<div className="Proposal-top-main-title-menu">
{isTrustee && (
<Dropdown
overlay={adminMenu}
trigger={['click']}
placement="bottomRight"
>
<Button>
<span>Actions</span>
<Icon type="down" style={{ marginRight: '-0.25rem' }} />
</Button>
</Dropdown>
)}
<Follow proposal={proposal} />
</div>
)}
</div>
<div className="Proposal-top-main-block" style={{ flexGrow: 1 }}>
<div
ref={el => (this.bodyEl = el)}
@ -206,21 +225,6 @@ export class ProposalDetail extends React.Component<Props, State> {
</button>
)}
</div>
{isLive &&
isTrustee && (
<div className="Proposal-top-main-menu">
<Dropdown
overlay={adminMenu}
trigger={['click']}
placement="bottomRight"
>
<Button>
<span>Actions</span>
<Icon type="down" style={{ marginRight: '-0.25rem' }} />
</Button>
</Dropdown>
</div>
)}
</div>
<div className="Proposal-top-side">
<CampaignBlock proposal={proposal} isPreview={!isLive} />

View File

@ -249,6 +249,8 @@ export function makeProposalPreviewFromDraft(draft: ProposalDraft): ProposalDeta
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
},
acceptedWithFunding: false,
authedFollows: false,
followersCount: 0,
isVersionTwo: true,
milestones: draft.milestones.map((m, idx) => ({
id: idx,

View File

@ -162,6 +162,8 @@ export function generateProposal({
stage: PROPOSAL_STAGE.WIP,
category: PROPOSAL_CATEGORY.COMMUNITY,
isStaked: true,
authedFollows: false,
followersCount: 0,
arbiter: {
status: PROPOSAL_ARBITER_STATUS.ACCEPTED,
user: {

View File

@ -64,6 +64,8 @@ export interface Proposal extends Omit<ProposalDraft, 'target' | 'invites'> {
arbiter: ProposalProposalArbiter;
acceptedWithFunding: boolean | null;
isVersionTwo: boolean;
authedFollows: boolean;
followersCount: number;
isTeamMember?: boolean; // FE derived
isArbiter?: boolean; // FE derived
}