From 5799ffab1912e1ff941c29b0d61d9beea8139c50 Mon Sep 17 00:00:00 2001 From: Danny Skubak Date: Wed, 23 Oct 2019 17:34:10 -0400 Subject: [PATCH] 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 --- admin/src/components/Emails/emails.ts | 11 +++ backend/grant/admin/example_emails.py | 9 +++ backend/grant/admin/views.py | 7 ++ backend/grant/email/send.py | 25 +++++- backend/grant/email/subscription_settings.py | 4 + backend/grant/proposal/models.py | 62 +++++++++++++-- backend/grant/proposal/views.py | 21 +++++ .../emails/followed_proposal_milestone.html | 31 ++++++++ .../emails/followed_proposal_milestone.txt | 3 + .../emails/followed_proposal_update.html | 29 +++++++ .../emails/followed_proposal_update.txt | 3 + backend/grant/user/models.py | 3 + backend/tests/proposal/test_api.py | 48 ++++++++++++ frontend/client/api/api.ts | 4 + frontend/client/components/AuthButton.tsx | 67 ++++++++++++++++ frontend/client/components/Follow/index.less | 24 ++++++ frontend/client/components/Follow/index.tsx | 76 +++++++++++++++++++ .../client/components/Proposal/index.less | 50 ++++++++---- frontend/client/components/Proposal/index.tsx | 40 +++++----- frontend/client/modules/create/utils.ts | 2 + frontend/stories/props.tsx | 2 + frontend/types/proposal.ts | 2 + 22 files changed, 484 insertions(+), 39 deletions(-) create mode 100644 backend/grant/templates/emails/followed_proposal_milestone.html create mode 100644 backend/grant/templates/emails/followed_proposal_milestone.txt create mode 100644 backend/grant/templates/emails/followed_proposal_update.html create mode 100644 backend/grant/templates/emails/followed_proposal_update.txt create mode 100644 frontend/client/components/AuthButton.tsx create mode 100644 frontend/client/components/Follow/index.less create mode 100644 frontend/client/components/Follow/index.tsx diff --git a/admin/src/components/Emails/emails.ts b/admin/src/components/Emails/emails.ts index 7312117e..f7df432b 100644 --- a/admin/src/components/Emails/emails.ts +++ b/admin/src/components/Emails/emails.ts @@ -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[]; diff --git a/backend/grant/admin/example_emails.py b/backend/grant/admin/example_emails.py index b1455aff..a357c534 100644 --- a/backend/grant/admin/example_emails.py +++ b/backend/grant/admin/example_emails.py @@ -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", + }, } diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index dc59dbe6..7642d9a9 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -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 diff --git a/backend/grant/email/send.py b/backend/grant/email/send.py index bc89282b..cdf9830e 100644 --- a/backend/grant/email/send.py +++ b/backend/grant/email/send.py @@ -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 } diff --git a/backend/grant/email/subscription_settings.py b/backend/grant/email/subscription_settings.py index 3a8f5482..a1b2f90a 100644 --- a/backend/grant/email/subscription_settings.py +++ b/backend/grant/email/subscription_settings.py @@ -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): diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index a95c1d15..fd90c0f4 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -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) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 3aedf526..dd7a051e 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -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("//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 + diff --git a/backend/grant/templates/emails/followed_proposal_milestone.html b/backend/grant/templates/emails/followed_proposal_milestone.html new file mode 100644 index 00000000..8e0f4d3e --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_milestone.html @@ -0,0 +1,31 @@ +

+ Your followed proposal {{ args.proposal.title }} has had its + {{ args.milestone.title }} + milestone accepted! +

+ + + + + +
+ + + + +
+ + Check it out + +
+
diff --git a/backend/grant/templates/emails/followed_proposal_milestone.txt b/backend/grant/templates/emails/followed_proposal_milestone.txt new file mode 100644 index 00000000..56d13cb4 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_milestone.txt @@ -0,0 +1,3 @@ +Your followed proposal {{ args.proposal.title }} has had its {{ args.milestone.title }} milestone accepted! + +Check it out: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/templates/emails/followed_proposal_update.html b/backend/grant/templates/emails/followed_proposal_update.html new file mode 100644 index 00000000..f5d3f280 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_update.html @@ -0,0 +1,29 @@ +

+ Your followed proposal {{ args.proposal.title }} has an update! +

+ + + + + +
+ + + + +
+ + Check it out + +
+
diff --git a/backend/grant/templates/emails/followed_proposal_update.txt b/backend/grant/templates/emails/followed_proposal_update.txt new file mode 100644 index 00000000..df11b955 --- /dev/null +++ b/backend/grant/templates/emails/followed_proposal_update.txt @@ -0,0 +1,3 @@ +Your followed proposal {{ args.proposal.title }} has an update! + +Check it out: {{ args.proposal_url }} \ No newline at end of file diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index de479a17..a2831a49 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -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, diff --git a/backend/tests/proposal/test_api.py b/backend/tests/proposal/test_api.py index 6f5b05a0..a645d9e9 100644 --- a/backend/tests/proposal/test_api.py +++ b/backend/tests/proposal/test_api.py @@ -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) \ No newline at end of file diff --git a/frontend/client/api/api.ts b/frontend/client/api/api.ts index 5f150056..b37d9809 100644 --- a/frontend/client/api/api.ts +++ b/frontend/client/api/api.ts @@ -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 }); } diff --git a/frontend/client/components/AuthButton.tsx b/frontend/client/components/AuthButton.tsx new file mode 100644 index 00000000..1cdcbb95 --- /dev/null +++ b/frontend/client/components/AuthButton.tsx @@ -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 { + state: State = { ...STATE }; + public render() { + const { location, children, loading } = this.props; + if (this.state.sendToAuth) { + return ; + } + return ( + + ); + } + private handleClick = (e: React.MouseEvent) => { + 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( + (state: AppState) => ({ + user: state.auth.user, + }), + { setAuthForwardLocation: authActions.setAuthForwardLocation }, +); + +export default compose( + withRouter, + withConnect, +)(AuthButton); diff --git a/frontend/client/components/Follow/index.less b/frontend/client/components/Follow/index.less new file mode 100644 index 00000000..c4b8aa2b --- /dev/null +++ b/frontend/client/components/Follow/index.less @@ -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; + } +} diff --git a/frontend/client/components/Follow/index.tsx b/frontend/client/components/Follow/index.tsx new file mode 100644 index 00000000..ed0db2a6 --- /dev/null +++ b/frontend/client/components/Follow/index.tsx @@ -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 { + state: State = { ...STATE }; + render() { + const { authedFollows, followersCount } = this.props.proposal; + const { loading } = this.state; + return ( + + + + {authedFollows ? ' Unfollow' : ' Follow'} + + + + ); + } + + 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( + state => ({ + authUser: state.auth.user, + }), + { + fetchProposal: proposalActions.fetchProposal, + }, +); + +export default withConnect(Follow); diff --git a/frontend/client/components/Proposal/index.less b/frontend/client/components/Proposal/index.less index c533ec65..a81616b8 100644 --- a/frontend/client/components/Proposal/index.less +++ b/frontend/client/components/Proposal/index.less @@ -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; + } } } diff --git a/frontend/client/components/Proposal/index.tsx b/frontend/client/components/Proposal/index.tsx index 9c3e9927..7e91a3ae 100644 --- a/frontend/client/components/Proposal/index.tsx +++ b/frontend/client/components/Proposal/index.tsx @@ -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 { )}
-

- {proposal ? proposal.title :  } -

+
+

{proposal ? proposal.title :  }

+ {isLive && ( +
+ {isTrustee && ( + + + + )} + +
+ )} +
+
(this.bodyEl = el)} @@ -206,21 +225,6 @@ export class ProposalDetail extends React.Component { )}
- {isLive && - isTrustee && ( -
- - - -
- )}
diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index b3c7db7b..001617c2 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -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, diff --git a/frontend/stories/props.tsx b/frontend/stories/props.tsx index 17c0447a..db29422d 100644 --- a/frontend/stories/props.tsx +++ b/frontend/stories/props.tsx @@ -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: { diff --git a/frontend/types/proposal.ts b/frontend/types/proposal.ts index e491fa7a..b19dcc41 100644 --- a/frontend/types/proposal.ts +++ b/frontend/types/proposal.ts @@ -64,6 +64,8 @@ export interface Proposal extends Omit { arbiter: ProposalProposalArbiter; acceptedWithFunding: boolean | null; isVersionTwo: boolean; + authedFollows: boolean; + followersCount: number; isTeamMember?: boolean; // FE derived isArbiter?: boolean; // FE derived }