def approve_pending(self, is_approve, reject_reason=None): self.validate_publishable() # specific validation if not self.status == ProposalStatus.PENDING: raise ValidationException(f"Proposal must be pending to approve or reject") if is_approve: self.status = ProposalStatus.APPROVED self.date_approved = datetime.datetime.now() for t in self.team: send_email(t.email_address, 'proposal_approved', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), 'admin_note': 'Congratulations! Your proposal has been approved.' }) else: if not reject_reason: raise ValidationException("Please provide a reason for rejecting the proposal") self.status = ProposalStatus.REJECTED self.reject_reason = reject_reason for t in self.team: send_email(t.email_address, 'proposal_rejected', { 'user': t, 'proposal': self, 'proposal_url': make_url(f'/proposals/{self.id}'), 'admin_note': reject_reason })
def paid_milestone_payout_request(id, mid): proposal = Proposal.query.filter_by(id=id).first() if not proposal: return {"message": "No proposal matching id"}, 404 if not proposal.status == ProposalStatus.LIVE: return {"message": "Proposal is not live"}, 400 for ms in proposal.milestones: if ms.id == int(mid): ms.mark_paid() admin.admin_log("MILESTONE_PAID", f"Paid milestone #{ms.index + 1} ({ms.title}) for proposal {proposal.id} ({proposal.title})") db.session.add(ms) db.session.flush() # check if this is the final ms, and update proposal.stage num_paid = reduce(lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0) if num_paid == len(proposal.milestones): proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED db.session.add(proposal) db.session.flush() db.session.commit() # email TEAM that payout request was PAID for member in proposal.team: send_email(member.email_address, 'milestone_paid', { 'proposal': proposal, 'milestone': ms, 'amount': ms.payout_amount, '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
def post_proposal_update(proposal_id, title, content): update = ProposalUpdate(proposal_id=g.current_proposal.id, title=title, content=content) db.session.add(update) db.session.commit() # Send email to all contributors for u in g.current_proposal.contributors: send_email( u.email_address, 'contribution_update', { 'proposal': g.current_proposal, 'proposal_update': update, '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
def accept_milestone_payout_request(proposal_id, milestone_id): if not g.current_proposal.is_funded: return {"message": "Proposal is not fully funded"}, 400 for ms in g.current_proposal.milestones: if ms.id == int(milestone_id): ms.accept_request(g.current_user.id) db.session.add(ms) db.session.commit() # email TEAM that payout request accepted amount = Decimal(ms.payout_percent) * Decimal( g.current_proposal.target) / 100 for member in g.current_proposal.team: send_email( member.email_address, 'milestone_accept', { 'proposal': g.current_proposal, 'amount': amount, 'proposal_milestones_url': make_url( f'/proposals/{g.current_proposal.id}?tab=milestones' ), }) return proposal_schema.dump(g.current_proposal), 200 return {"message": "No milestone matching id"}, 404
def reject_permanently_proposal(proposal_id, reject_reason): proposal = Proposal.query.get(proposal_id) if not proposal: return {"message": "No proposal found."}, 404 reject_permanently_statuses = [ ProposalStatus.REJECTED, ProposalStatus.PENDING ] if proposal.status not in reject_permanently_statuses: return {"message": "Proposal status is not REJECTED or PENDING."}, 401 proposal.status = ProposalStatus.REJECTED_PERMANENTLY proposal.reject_reason = reject_reason db.session.add(proposal) db.session.commit() for user in proposal.team: send_email( user.email_address, 'proposal_rejected_permanently', { 'user': user, 'proposal': proposal, 'proposal_url': make_url(f'/proposals/{proposal.id}'), 'admin_note': reject_reason, 'profile_rejected_url': make_url('/profile?tab=rejected'), }) return proposal_schema.dump(proposal)
def reject_permanently_ccr(ccr_id, reject_reason): ccr = CCR.query.get(ccr_id) if not ccr: return {"message": "No CCR found."}, 404 reject_permanently_statuses = [CCRStatus.REJECTED, CCRStatus.PENDING] if ccr.status not in reject_permanently_statuses: return {"message": "CCR status is not REJECTED or PENDING."}, 401 ccr.status = CCRStatus.REJECTED_PERMANENTLY ccr.reject_reason = reject_reason db.session.add(ccr) db.session.commit() send_email( ccr.author.email_address, 'ccr_rejected_permanently', { 'user': ccr.author, 'ccr': ccr, 'admin_note': reject_reason, 'profile_rejected_url': make_url('/profile?tab=rejected') }) return ccr_schema.dump(ccr)
def process_task(task): from grant.proposal.models import Proposal, ProposalUpdate from grant.milestone.models import Milestone proposal_id = task.blob["proposal_id"] milestone_id = task.blob["milestone_id"] update_count = task.blob["update_count"] proposal = Proposal.query.filter_by(id=proposal_id).first() milestone = Milestone.query.filter_by(id=milestone_id).first() current_update_count = len( ProposalUpdate.query.filter_by(proposal_id=proposal_id).all()) # if proposal was deleted or cancelled, noop out if not proposal or proposal.status == ProposalStatus.DELETED or proposal.stage == ProposalStage.CANCELED: return # if milestone was deleted, noop out if not milestone: return # if milestone payout has been requested or an update has been posted, noop out if current_update_count > update_count or milestone.date_requested: return # send email to arbiter notifying milestone deadline has been missed send_email( proposal.arbiter.user.email_address, 'milestone_deadline', { 'proposal': proposal, 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), })
def process_task(task): from grant.proposal.models import Proposal proposal = Proposal.query.filter_by( id=task.blob["proposal_id"]).first() # If it was deleted, canceled, or successful, just noop out if not proposal or proposal.is_funded or proposal.stage != ProposalStage.FUNDING_REQUIRED: return # Otherwise, mark it as failed and inform everyone proposal.stage = ProposalStage.FAILED db.session.add(proposal) db.session.commit() # Send emails to team & contributors for u in proposal.team: send_email(u.email_address, 'proposal_failed', { 'proposal': proposal, }) for u in proposal.contributors: send_email( u.email_address, 'contribution_proposal_failed', { 'proposal': proposal, 'refund_address': u.settings.refund_address, 'account_settings_url': make_url('/profile/settings?tab=account') })
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 })
def send_verification_email(self): send_email( self.email_address, 'signup', { 'display_name': self.display_name, 'confirm_url': make_url(f'/email/verify?code={self.email_verification.code}') })
def set_password(self, password: str): self.password = hash_password(password) db.session.commit() send_email( self.email_address, 'change_password', { 'display_name': self.display_name, 'recover_url': make_url('/auth/recover'), 'contact_url': make_url('/contact') })
def send_admin_email(self, type: str): from grant.user.models import User admins = User.get_admins() for a in admins: send_email(a.email_address, type, { 'user': a, 'ccr': self, 'ccr_url': make_admin_url(f'/ccrs/{self.id}'), })
def paid_milestone_payout_request(id, mid, tx_id): proposal = Proposal.query.filter_by(id=id).first() if not proposal: return {"message": "No proposal matching id"}, 404 if not proposal.is_funded: return {"message": "Proposal is not fully funded"}, 400 for ms in proposal.milestones: if ms.id == int(mid): is_final_milestone = False ms.mark_paid(tx_id) db.session.add(ms) db.session.flush() # check if this is the final ms, and update proposal.stage num_paid = reduce( lambda a, x: a + (1 if x.stage == MilestoneStage.PAID else 0), proposal.milestones, 0) if num_paid == len(proposal.milestones): is_final_milestone = True proposal.stage = ProposalStage.COMPLETED # WIP -> COMPLETED db.session.add(proposal) db.session.flush() db.session.commit() # email TEAM that payout request was PAID amount = Decimal(ms.payout_percent) * Decimal( proposal.target) / 100 for member in proposal.team: send_email( member.email_address, 'milestone_paid', { 'proposal': proposal, 'milestone': ms, 'amount': amount, '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", ) if not is_final_milestone: Milestone.set_v2_date_estimates(proposal) db.session.commit() return proposal_schema.dump(proposal), 200 return {"message": "No milestone matching id"}, 404
def send_recovery_email(self): existing = self.email_recovery if existing: db.session.delete(existing) er = EmailRecovery(user_id=self.id) db.session.add(er) db.session.commit() send_email( self.email_address, 'recover', { 'display_name': self.display_name, 'recover_url': make_url(f'/email/recover?code={er.code}'), })
def cancel(self): if self.status != ProposalStatus.LIVE: raise ValidationException("Cannot cancel a proposal until it's live") self.stage = ProposalStage.CANCELED db.session.add(self) db.session.flush() # Send emails to team & contributors for u in self.team: send_email(u.email_address, 'proposal_canceled', { 'proposal': self, 'support_url': make_url('/contact'), })
def approve_pending(self, is_approve, reject_reason=None): from grant.rfp.models import RFP self.validate_publishable() # specific validation if not self.status == CCRStatus.PENDING: raise ValidationException(f"CCR must be pending to approve or reject") if is_approve: self.status = CCRStatus.LIVE rfp = RFP( title=self.title, brief=self.brief, content=self.content, bounty=self._target, date_closes=datetime.now() + timedelta(days=90), ) db.session.add(self) db.session.add(rfp) db.session.flush() self.rfp_id = rfp.id db.session.add(rfp) db.session.flush() # for emails db.session.commit() send_email(self.author.email_address, 'ccr_approved', { 'user': self.author, 'ccr': self, 'admin_note': f'Congratulations! Your Request has been accepted. There may be a delay between acceptance and final posting as required by the Zcash Foundation.' }) return rfp.id else: if not reject_reason: raise ValidationException("Please provide a reason for rejecting the ccr") self.status = CCRStatus.REJECTED self.reject_reason = reject_reason # for emails db.session.add(self) db.session.commit() send_email(self.author.email_address, 'ccr_rejected', { 'user': self.author, 'ccr': self, 'admin_note': reject_reason }) return None
def set_arbiter(proposal_id, user_id): proposal = Proposal.query.filter(Proposal.id == proposal_id).first() if not proposal: return {"message": "Proposal not found"}, 404 for member in proposal.team: if member.id == user_id: return { "message": "Cannot set proposal team member as arbiter" }, 400 if proposal.is_failed: return {"message": "Cannot set arbiter on failed proposal"}, 400 if proposal.version == '2' and not proposal.accepted_with_funding: return { "message": "Cannot set arbiter, proposal has not been accepted with funding" }, 400 user = User.query.filter(User.id == user_id).first() if not user: return {"message": "User not found"}, 404 # send email code = user.email_verification.code send_email( user.email_address, 'proposal_arbiter', { 'proposal': proposal, 'proposal_url': make_url(f'/proposals/{proposal.id}'), 'accept_url': make_url(f'/email/arbiter?code={code}&proposalId={proposal.id}'), }) proposal.arbiter.user = user proposal.arbiter.status = ProposalArbiterStatus.NOMINATED db.session.add(proposal.arbiter) db.session.commit() return { 'proposal': proposal_schema.dump(proposal), 'user': admin_user_schema.dump(user) }, 200
def reject_milestone_payout_request(proposal_id, milestone_id, reason): proposal = Proposal.query.filter_by(id=proposal_id).first() if not proposal: return {"message": "No proposal matching id"}, 404 for ms in proposal.milestones: if ms.id == int(milestone_id): ms.reject_request(reason) admin.admin_log("MILESTONE_REJECT", f"Rejected milestone #{ms.index + 1} ({ms.title}) for proposal {proposal.id} ({proposal.title}) with reason '{reason}'") db.session.add(ms) db.session.commit() # email TEAM that payout request was rejected for member in proposal.team: send_email(member.email_address, 'milestone_reject', { 'proposal': proposal, 'admin_note': reason, 'proposal_milestones_url': make_url(f'/proposals/{proposal.id}?tab=milestones'), }) return proposal_schema.dump(proposal), 200 return {"message": "No milestone matching id"}, 404
def request_milestone_payout(proposal_id, milestone_id): if not g.current_proposal.is_funded: return {"message": "Proposal is not fully funded"}, 400 for ms in g.current_proposal.milestones: if ms.id == int(milestone_id): ms.request_payout(g.current_user.id) db.session.add(ms) db.session.commit() # email ARBITER to review payout request send_email( g.current_proposal.arbiter.user.email_address, 'milestone_request', { 'proposal': g.current_proposal, 'proposal_milestones_url': make_url( f'/proposals/{g.current_proposal.id}?tab=milestones'), }) return proposal_schema.dump(g.current_proposal), 200 return {"message": "No milestone matching id"}, 404
def update_rfw_worker_accept(rfw_id, worker_id, is_accept, message=''): rfw = rfw_models.RFW.query.get(rfw_id) if not rfw: return {"message": "No RFW matching that id"}, 404 w = rfw_models.RFWWorker.query.get(worker_id) if not w: return {"message": "No worker matching that id"}, 404 if is_accept: rfw.accept_worker_by_id(worker_id, message) admin.admin_log("WORKER_ACCEPT", f"Accepted worker {worker_id} for RFW {rfw.id} with message '{message}'") else: rfw.reject_worker_by_id(worker_id, message) admin.admin_log("WORKER_REJECT", f"Rejected worker {worker_id} for RFW {rfw.id} with message '{message}'") # notify worker send_email(w.user.email_address, 'worker_approved' if is_accept else 'worker_rejected', { 'rfw': rfw, 'message': message, 'rfw_url': make_url(f'/rfws/{rfw.id}'), }) db.session.commit() return rfw_models.rfw_schemas.single_admin.dump(rfw)
def update_rfw_milestone_claim_accept(rfw_id, ms_id, claim_id, is_accept, message=''): rfw = rfw_models.RFW.query.get(rfw_id) if not rfw: return {"message": "No RFW matching that id"}, 404 if is_accept: rfw.accept_milestone_claim(ms_id, claim_id, message) admin.admin_log("CLAIM_ACCEPT", f"Accepted claim {claim_id} for RFW {rfw_id} with message '{message}'") else: rfw.reject_milestone_claim(ms_id, claim_id, message) admin.admin_log("CLAIM_REJECT", f"Rejected claim {claim_id} for RFW {rfw_id} with message '{message}'") # notify worker ms = rfw.get_milestone_by_id(ms_id) w = ms.get_claim_by_id(claim_id).worker send_email(w.user.email_address, 'work_milestone_accepted' if is_accept else 'work_milestone_rejected', { 'rfw': rfw, 'milestone': ms, 'message': message, 'rfw_url': make_url(f'/rfws/{rfw.id}'), }) db.session.commit() return rfw_models.rfw_schemas.single_admin.dump(rfw)
def create(email_address=None, account_address=None, display_name=None, title=None, _send_email=True): user = User( account_address=account_address, email_address=email_address, display_name=display_name, title=title ) db.session.add(user) db.session.flush() # Setup & send email verification ev = EmailVerification(user_id=user.id) db.session.add(ev) db.session.commit() if send_email: send_email(user.email_address, 'signup', { 'display_name': user.display_name, 'confirm_url': make_url(f'/email/verify?code={ev.code}') }) return user
def set_email(self, email: str): # Update email address old_email = self.email_address self.email_address = email # Delete old verification(s?) old_evs = EmailVerification.query.filter_by(user_id=self.id).all() for old_ev in old_evs: db.session.delete(old_ev) # Generate a new one ev = EmailVerification(user_id=self.id) db.session.add(ev) # Save changes & send notification & verification emails db.session.commit() send_email(old_email, 'change_email_old', { 'display_name': self.display_name, 'contact_url': make_url('/contact') }) send_email( self.email_address, 'change_email', { 'display_name': self.display_name, 'confirm_url': make_url(f'/email/verify?code={ev.code}') })
def process_task(task): from grant.proposal.models import ProposalContribution contribution = ProposalContribution.query.filter_by( id=task.blob["contribution_id"]).first() # If it's missing or not pending, noop out if not contribution or contribution.status != ContributionStatus.PENDING: return # Otherwise, inform the user (if not anonymous) if contribution.user: send_email( contribution.user.email_address, 'contribution_expired', { 'contribution': contribution, 'proposal': contribution.proposal, 'contact_url': make_url('/contact'), 'profile_url': make_url(f'/profile/{contribution.user.id}'), 'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'), })
def post_proposal_team_invite(proposal_id, address): for u in g.current_proposal.team: if address == u.email_address: return { "message": f"Cannot invite members already on the team" }, 400 existing_invite = ProposalTeamInvite.query.filter_by( proposal_id=proposal_id, address=address).first() if existing_invite: return {"message": f"You've already invited {address}"}, 400 invite = ProposalTeamInvite(proposal_id=proposal_id, address=address) db.session.add(invite) db.session.commit() # Send email email = address user = User.get_by_email(email_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, 'invite_url': make_url( f'/profile/{user.id}?tab=invites' if user else '/auth') }) return proposal_team_invite_schema.dump(invite), 201
def set_user_arbiter(user_id, proposal_id, is_accept): try: proposal = Proposal.query.filter_by(id=int(proposal_id)).first() if not proposal: return {"message": "No such proposal"}, 404 if is_accept: proposal.arbiter.accept_nomination(g.current_user.id) for user in proposal.team: send_email( user.email_address, 'proposal_arbiter_assigned', { 'user': user, 'proposal': proposal, 'proposal_url': make_url(f'/proposals/{proposal.id}') }) return {"message": "Accepted nomination"}, 200 else: proposal.arbiter.reject_nomination(g.current_user.id) return {"message": "Rejected nomination"}, 200 except ValidationException as e: return {"message": str(e)}, 400
def reject_milestone_payout_request(proposal_id, milestone_id, reason): if not g.current_proposal.is_funded: return {"message": "Proposal is not fully funded"}, 400 for ms in g.current_proposal.milestones: if ms.id == int(milestone_id): ms.reject_request(g.current_user.id, reason) db.session.add(ms) db.session.commit() # email TEAM that payout request was rejected for member in g.current_proposal.team: send_email( member.email_address, 'milestone_reject', { 'proposal': g.current_proposal, 'admin_note': reason, 'proposal_milestones_url': make_url( f'/proposals/{g.current_proposal.id}?tab=milestones' ), }) return proposal_schema.dump(g.current_proposal), 200 return {"message": "No milestone matching id"}, 404
def post_contribution_confirmation(contribution_id, to, amount, txid): contribution = ProposalContribution.query.filter_by( id=contribution_id).first() if not contribution: msg = f'Unknown contribution {contribution_id} confirmed with txid {txid}, amount {amount}' capture_message(msg) current_app.logger.warn(msg) return {"message": "No contribution matching id"}, 404 if contribution.status == ContributionStatus.CONFIRMED: # Duplicates can happen, just return ok return {"message": "ok"}, 200 # Convert to whole zcash coins from zats zec_amount = str(from_zat(int(amount))) contribution.confirm(tx_id=txid, amount=zec_amount) db.session.add(contribution) db.session.flush() if contribution.proposal.status == ProposalStatus.STAKING: contribution.proposal.set_pending_when_ready() # email progress of staking, partial or complete send_email( contribution.user.email_address, 'staking_contribution_confirmed', { 'contribution': contribution, 'proposal': contribution.proposal, 'tx_explorer_url': make_explore_url(txid), 'fully_staked': contribution.proposal.is_staked, 'stake_target': str(PROPOSAL_STAKING_AMOUNT.normalize()), }) else: # Send to the user if contribution.user: send_email( contribution.user.email_address, 'contribution_confirmed', { 'contribution': contribution, 'proposal': contribution.proposal, 'tx_explorer_url': make_explore_url(txid), }) # Send to the full proposal gang for member in contribution.proposal.team: send_email( member.email_address, 'proposal_contribution', { 'proposal': contribution.proposal, 'contribution': contribution, 'contributor': contribution.user, 'funded': contribution.proposal.funded, 'proposal_url': make_url(f'/proposals/{contribution.proposal.id}'), 'contributor_url': make_url(f'/profile/{contribution.user.id}') if contribution.user else '', }) db.session.commit() return {"message": "ok"}, 200
def post_proposal_comments(proposal_id, comment, parent_comment_id): # Make sure proposal exists proposal = Proposal.query.filter_by(id=proposal_id).first() if not proposal: return {"message": "No proposal matching id"}, 404 if proposal.status != ProposalStatus.LIVE and proposal.status != ProposalStatus.DISCUSSION: return { "message": "Proposal must be live or open for public review to comment" }, 400 # Make sure the parent comment exists parent = None if parent_comment_id: parent = Comment.query.filter_by(id=parent_comment_id).first() if not parent: return {"message": "Parent comment doesn’t exist"}, 400 # Make sure user has verified their email if not g.current_user.email_verification.has_verified: return {"message": "Please confirm your email before commenting"}, 401 # Make sure user is not silenced if g.current_user.silenced: return { "message": "Your account has been silenced, commenting is disabled." }, 403 # Make the comment comment = Comment(proposal_id=proposal_id, user_id=g.current_user.id, parent_comment_id=parent_comment_id, content=comment) db.session.add(comment) db.session.commit() dumped_comment = comment_schema.dump(comment) # Email proposal team if top-level comment if not parent: for member in proposal.team: send_email( member.email_address, 'proposal_comment', { 'author': g.current_user, 'proposal': proposal, 'comment_url': make_url( f'/proposals/{proposal.id}?tab=discussions&comment={comment.id}' ), 'author_url': make_url(f'/profile/{comment.author.id}'), }) # Email parent comment creator, if it's not themselves if parent and parent.author.id != comment.author.id: send_email( parent.author.email_address, 'comment_reply', { 'author': g.current_user, 'proposal': proposal, 'comment_url': make_url( f'/proposals/{proposal.id}?tab=discussions&comment={comment.id}' ), 'author_url': make_url(f'/profile/{comment.author.id}'), }) return dumped_comment, 201