From d9a2350362f506012d04c927d7fd09b7f5cf712c Mon Sep 17 00:00:00 2001 From: Kevin Owocki Date: Mon, 6 Apr 2020 12:21:02 -0600 Subject: [PATCH 1/2] Revert "Revert "As a user, I would like to be able to request money from someon, so I can get paid and remind a forgetful funder."" --- app/app/urls.py | 2 +- app/assets/onepager/js/request.js | 83 ++++++++++++ .../migrations/0093_auto_20200324_1653.py | 37 ++++++ app/dashboard/models.py | 22 +++- app/dashboard/templates/onepager/send2.html | 4 +- app/dashboard/templates/request_payment.html | 119 ++++++++++++++++++ app/dashboard/tip_views.py | 65 ++++++++-- app/marketing/mails.py | 19 ++- app/retail/emails.py | 21 ++++ .../templates/emails/request_funds.html | 68 ++++++++++ app/retail/templates/emails/request_funds.txt | 13 ++ 11 files changed, 438 insertions(+), 15 deletions(-) create mode 100644 app/assets/onepager/js/request.js create mode 100644 app/dashboard/migrations/0093_auto_20200324_1653.py create mode 100644 app/dashboard/templates/request_payment.html create mode 100644 app/retail/templates/emails/request_funds.html create mode 100644 app/retail/templates/emails/request_funds.txt diff --git a/app/app/urls.py b/app/app/urls.py index 17e0aec5599..b80603f8681 100644 --- a/app/app/urls.py +++ b/app/app/urls.py @@ -331,7 +331,7 @@ url(r'^tip/send/?', dashboard.tip_views.send_tip, name='send_tip'), url(r'^send/?', dashboard.tip_views.send_tip, name='tip'), url(r'^tip/?', dashboard.tip_views.send_tip_2, name='tip'), - + url(r'^requestmoney/?', dashboard.tip_views.request_money, name='request_money'), # Legal re_path(r'^terms/?', dashboard.views.terms, name='_terms'), re_path(r'^legal/terms/?', dashboard.views.terms, name='terms'), diff --git a/app/assets/onepager/js/request.js b/app/assets/onepager/js/request.js new file mode 100644 index 00000000000..e932db248d0 --- /dev/null +++ b/app/assets/onepager/js/request.js @@ -0,0 +1,83 @@ +$(document).ready(function() { + $('#request').on('click', function(e) { + e.preventDefault(); + if ($(this).hasClass('disabled')) + return; + loading_button($(this)); + // get form data + var username = $('.username-search').select2('data')[0] ? $('.username-search').select2('data')[0].text : ''; + var amount = parseFloat($('#amount').val()); + var comments = $('#comments').val(); + var tokenAddress = ( + ($('#token').val() == '0x0') ? + '0x0000000000000000000000000000000000000000' + : $('#token').val()); + + + // derived info + var isSendingETH = (tokenAddress == '0x0' || tokenAddress == '0x0000000000000000000000000000000000000000'); + var tokenDetails = tokenAddressToDetails(tokenAddress); + var tokenName = 'ETH'; + + if (!isSendingETH) { + tokenName = tokenDetails.name; + } + + if (!username) { + _alert('Please enter a recipient', 'error'); + return; + } + + var success_callback = function() { + unloading_button($('#request')); + }; + var failure_callback = function() { + unloading_button($('#request')); + }; + + return requestFunds(username, amount, comments, tokenAddress, tokenName, success_callback, failure_callback); + + }); + +}); + +function requestFunds(username, amount, comments, tokenAddress, tokenName, success_callback, failure_callback) { + if (username.indexOf('@') == -1) { + username = '@' + username; + } + + var tokenDetails = tokenAddressToDetails(tokenAddress); + + if (!isNumeric(amount) || amount == 0) { + _alert({ message: gettext('You must enter an number for the amount!') }, 'warning'); + failure_callback(); + return; + } + if (username == '') { + _alert({ message: gettext('You must enter a username.') }, 'warning'); + failure_callback(); + return; + } + + const csrfmiddlewaretoken = $('[name=csrfmiddlewaretoken]').val(); + const url = '/requestmoney'; + const formData = new FormData(); + + formData.append('username', username); + formData.append('amount', amount); + formData.append('tokenName', tokenName); + formData.append('comments', comments); + formData.append('tokenAddress', tokenAddress); + formData.append('csrfmiddlewaretoken', csrfmiddlewaretoken); + + fetch(url, { + method: 'POST', + body: formData + }).then(function(json) { + _alert('The funder has been notified', 'success'); + success_callback() + }).catch(function (error) { + _alert('Something goes wrong, try later.', 'error'); + failure_callback() + }); +} diff --git a/app/dashboard/migrations/0093_auto_20200324_1653.py b/app/dashboard/migrations/0093_auto_20200324_1653.py new file mode 100644 index 00000000000..abd136654a5 --- /dev/null +++ b/app/dashboard/migrations/0093_auto_20200324_1653.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.4 on 2020-03-24 16:53 + +from django.db import migrations, models +import django.db.models.deletion +import economy.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0092_auto_20200316_2228'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='activity_type', + field=models.CharField(blank=True, choices=[('wall_post', 'Wall Post'), ('status_update', 'Update status'), ('new_bounty', 'New Bounty'), ('start_work', 'Work Started'), ('stop_work', 'Work Stopped'), ('work_submitted', 'Work Submitted'), ('work_done', 'Work Done'), ('worker_approved', 'Worker Approved'), ('worker_rejected', 'Worker Rejected'), ('worker_applied', 'Worker Applied'), ('increased_bounty', 'Increased Funding'), ('killed_bounty', 'Canceled Bounty'), ('new_tip', 'New Tip'), ('receive_tip', 'Tip Received'), ('bounty_abandonment_escalation_to_mods', 'Escalated checkin from @gitcoinbot about bounty status'), ('bounty_abandonment_warning', 'Checkin from @gitcoinbot about bounty status'), ('bounty_removed_slashed_by_staff', 'Dinged and Removed from Bounty by Staff'), ('bounty_removed_by_staff', 'Removed from Bounty by Staff'), ('bounty_removed_by_funder', 'Removed from Bounty by Funder'), ('new_crowdfund', 'New Crowdfund Contribution'), ('new_grant', 'New Grant'), ('update_grant', 'Updated Grant'), ('killed_grant', 'Cancelled Grant'), ('new_grant_contribution', 'Contributed to Grant'), ('new_grant_subscription', 'Subscribed to Grant'), ('killed_grant_contribution', 'Cancelled Grant Contribution'), ('new_milestone', 'New Milestone'), ('update_milestone', 'Updated Milestone'), ('new_kudos', 'New Kudos'), ('created_kudos', 'Created Kudos'), ('receive_kudos', 'Receive Kudos'), ('joined', 'Joined Gitcoin'), ('played_quest', 'Played Quest'), ('beat_quest', 'Beat Quest'), ('created_quest', 'Created Quest'), ('updated_avatar', 'Updated Avatar'), ('mini_clr_payout', 'Mini CLR Payout'), ('leaderboard_rank', 'Leaderboard Rank'), ('consolidated_leaderboard_rank', 'Consolidated Leaderboard Rank'), ('consolidated_mini_clr_payout', 'Consolidated CLR Payout'), ('hackathon_registration', 'Hackathon Registration')], db_index=True, max_length=50), + ), + migrations.CreateModel( + name='FundRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('modified_on', models.DateTimeField(default=economy.models.get_time)), + ('token_name', models.CharField(default='ETH', max_length=255)), + ('amount', models.DecimalField(decimal_places=4, default=1, max_digits=50)), + ('comments', models.TextField(blank=True, default='')), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests_receiver', to='dashboard.Profile')), + ('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests_sender', to='dashboard.Profile')), + ('tip', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='dashboard.Tip')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/app/dashboard/models.py b/app/dashboard/models.py index 2996b23fa1c..4b04fc9fdcd 100644 --- a/app/dashboard/models.py +++ b/app/dashboard/models.py @@ -62,7 +62,7 @@ _AUTH, HEADERS, TOKEN_URL, build_auth_dict, get_gh_issue_details, get_issue_comments, issue_number, org_name, repo_name, ) -from marketing.mails import featured_funded_bounty, start_work_approved +from marketing.mails import featured_funded_bounty, start_work_approved, fund_request_email from marketing.models import LeaderboardRank from rest_framework import serializers from web3 import Web3 @@ -1773,6 +1773,26 @@ def __str__(self): return f"tip: {self.tip.pk} profile: {self.profile.handle}" +class FundRequest(SuperModel): + profile = models.ForeignKey( + 'dashboard.Profile', related_name='requests_receiver', on_delete=models.CASCADE + ) + requester = models.ForeignKey( + 'dashboard.Profile', related_name='requests_sender', on_delete=models.CASCADE + ) + token_name = models.CharField(max_length=255, default='ETH') + amount = models.DecimalField(default=1, decimal_places=4, max_digits=50) + comments = models.TextField(default='', blank=True) + tip = models.OneToOneField(Tip, on_delete=models.CASCADE, null=True) + created_on = models.DateTimeField(auto_now_add=True) + + +@receiver(post_save, sender=FundRequest, dispatch_uid="post_save_fund_request") +def psave_fund_request(sender, instance, created, **kwargs): + if created: + fund_request_email(instance, [instance.profile.email]) + + @receiver(pre_save, sender=Tip, dispatch_uid="psave_tip") def psave_tip(sender, instance, **kwargs): # when a new tip is saved, make sure it doesnt have whitespace in it diff --git a/app/dashboard/templates/onepager/send2.html b/app/dashboard/templates/onepager/send2.html index 6abc8722c5c..0e145ed70ab 100644 --- a/app/dashboard/templates/onepager/send2.html +++ b/app/dashboard/templates/onepager/send2.html @@ -80,10 +80,10 @@

{% trans "Send Tip." %}


{% trans "Amount of" %} - - +
 
{% trans "Where is my Eth going? " %}

diff --git a/app/dashboard/templates/request_payment.html b/app/dashboard/templates/request_payment.html new file mode 100644 index 00000000000..55be739bba5 --- /dev/null +++ b/app/dashboard/templates/request_payment.html @@ -0,0 +1,119 @@ +{% extends 'onepager/base.html' %} +{% comment %} + Copyright (C) 2020 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +{% endcomment %} +{% load i18n static %} +{% block 'scripts' %} + {% include 'shared/current_profile.html' %} + + + + + + + + + + + + + +{% endblock %} + +{% block 'main' %} + + +
+ +
+
+
+ + + Helmet + + +
+
+

{% trans "Request money." %}

+ {% csrf_token %} +
+
+
+
+ +
+ {% trans "Amount of" %} + + +
+
 
+
+ {% trans "Comments" %}: + +
+
+ + +
+ {% trans "Send" %} ⚡️ +
+
+
+ + {% if not user_json and username %} + + {% endif %} +{% endblock %} diff --git a/app/dashboard/tip_views.py b/app/dashboard/tip_views.py index fe680e49514..7e56577f8ab 100644 --- a/app/dashboard/tip_views.py +++ b/app/dashboard/tip_views.py @@ -24,6 +24,7 @@ from django.conf import settings from django.contrib import messages +from django.contrib.auth.decorators import login_required from django.http import JsonResponse from django.shortcuts import redirect from django.template.response import TemplateResponse @@ -39,7 +40,7 @@ from retail.helpers import get_ip from web3 import Web3 -from .models import Activity, Profile, Tip, TipPayout +from .models import Activity, Profile, Tip, TipPayout, FundRequest from .notifications import maybe_market_tip_to_email, maybe_market_tip_to_github, maybe_market_tip_to_slack logging.basicConfig(level=logging.DEBUG) @@ -60,6 +61,38 @@ def send_tip(request): return TemplateResponse(request, 'onepager/send1.html', params) +def request_money(request): + """""" + if request.method == 'POST': + username = request.POST.get('username', '').strip('@') + token_name = request.POST.get('tokenName') + amount = request.POST.get('amount') + comments = request.POST.get('comments') + token_address = request.POST.get('tokenAddress') + profiles = Profile.objects.filter(handle=username.lower()) + + if profiles.exists(): + profile = profiles.first() + kwargs = { + 'profile': profile, + 'token_name': token_name, + 'amount': amount, + 'comments': comments, + 'requester': request.user.profile, + } + fund_request = FundRequest.objects.create(**kwargs) + messages.success(request, f'Stay tuned, {profile.handle} has been notified by email.') + else: + messages.error(request, f'The user {username} doesn\'t exists.') + + params = { + 'class': 'send2', + 'title': 'Request Money | Gitcoin', + 'card_desc': 'Request money from any user at the click of a button.', + } + + return TemplateResponse(request, 'request_payment.html', params) + def record_tip_activity(tip, github_handle, event_name, override_created=None, other_handle=None): kwargs = { 'created_on': timezone.now() if not override_created else override_created, @@ -413,8 +446,10 @@ def send_tip_2(request): TemplateResponse: Render the submission form. """ - + profile = None + fund_request = None username = request.GET.get('username', None) + pk_fund_request = request.GET.get('request', None) is_user_authenticated = request.user.is_authenticated from_username = request.user.username if is_user_authenticated else '' primary_from_email = request.user.email if is_user_authenticated else '' @@ -422,17 +457,26 @@ def send_tip_2(request): user = {} if username: profiles = Profile.objects.filter(handle=username.lower()) - if profiles.exists(): profile = profiles.first() - user['id'] = profile.id - user['text'] = profile.handle - user['avatar_url'] = profile.avatar_url - if profile.avatar_baseavatar_related.exists(): - user['avatar_id'] = profile.avatar_baseavatar_related.filter(active=True).first().pk - user['avatar_url'] = profile.avatar_baseavatar_related.filter(active=True).first().avatar_url - user['preferred_payout_address'] = profile.preferred_payout_address + if pk_fund_request: + requests = FundRequest.objects.filter(pk=int(pk_fund_request)) + if requests.exists(): + fund_request = requests.first() + profile = fund_request.requester + else: + messages.error(f'Failed to retrieve the fund request {fund_request}') + + if profile: + user['id'] = profile.id + user['text'] = profile.handle + user['avatar_url'] = profile.avatar_url + + if profile.avatar_baseavatar_related.exists(): + user['avatar_id'] = profile.avatar_baseavatar_related.filter(active=True).first().pk + user['avatar_url'] = profile.avatar_baseavatar_related.filter(active=True).first().avatar_url + user['preferred_payout_address'] = profile.preferred_payout_address params = { 'issueURL': request.GET.get('source'), @@ -442,6 +486,7 @@ def send_tip_2(request): 'from_handle': from_username, 'title': 'Send Tip | Gitcoin', 'card_desc': 'Send a tip to any github user at the click of a button.', + 'fund_request': fund_request } if user: diff --git a/app/marketing/mails.py b/app/marketing/mails.py index c0111e1c508..3f72dd339a4 100644 --- a/app/marketing/mails.py +++ b/app/marketing/mails.py @@ -42,7 +42,7 @@ render_start_work_new_applicant, render_start_work_rejected, render_subscription_terminated_email, render_successful_contribution_email, render_support_cancellation_email, render_thank_you_for_supporting_email, render_tip_email, render_unread_notification_email_weekly_roundup, render_wallpost, render_weekly_recap, -) + render_request_amount_email) from sendgrid.helpers.mail import Content, Email, Mail, Personalization from sendgrid.helpers.stats import Category from townsquare.utils import is_email_townsquare_enabled, is_there_an_action_available @@ -1578,3 +1578,20 @@ def bounty_request_feedback(profile): ) finally: translation.activate(cur_language) + + +def fund_request_email(request, to_emails, is_new=False): + subject = gettext("🕐 New Request funds from {} ({} {})").format(request.requester.handle, + request.amount, + request.token_name) + for to_email in to_emails: + cur_language = translation.get_language() + try: + setup_lang(to_email) + from_email = settings.CONTACT_EMAIL + html, text = render_request_amount_email(to_email, request, is_new) + + if not should_suppress_notification_email(to_email, 'tip'): + send_mail(from_email, to_email, subject, text, html, categories=['transactional', func_name()]) + finally: + translation.activate(cur_language) diff --git a/app/retail/emails.py b/app/retail/emails.py index b5572b387c0..7819f23caa3 100644 --- a/app/retail/emails.py +++ b/app/retail/emails.py @@ -27,6 +27,7 @@ from django.shortcuts import redirect from django.template.loader import render_to_string from django.template.response import TemplateResponse +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext as _ @@ -260,6 +261,26 @@ def render_tip_email(to_email, tip, is_new): return response_html, response_txt +def render_request_amount_email(to_email, request, is_new): + + link = f'{reverse("tip")}?request={request.id}' + params = { + 'link': link, + 'amount': request.amount, + 'tokenName': request.token_name, + 'comments': request.comments, + 'subscriber': get_or_save_email_subscriber(to_email, 'internal'), + 'email_type': 'request', + 'request': request, + 'already_received': request.tip + } + + response_html = premailer_transform(render_to_string("emails/request_funds.html", params)) + response_txt = render_to_string("emails/new_tip.txt", params) + + return response_html, response_txt + + def render_kudos_email(to_email, kudos_transfer, is_new, html_template, text_template=None): """Summary diff --git a/app/retail/templates/emails/request_funds.html b/app/retail/templates/emails/request_funds.html new file mode 100644 index 00000000000..6549f0845b3 --- /dev/null +++ b/app/retail/templates/emails/request_funds.html @@ -0,0 +1,68 @@ +{% extends 'emails/template.html' %} +{% comment %} + Copyright (C) 2019 Gitcoin Core + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +{% endcomment %} +{% load i18n humanize %} + +{% block content %} + + + +
+

🕑️ {% trans "Request funds" %} 🕑️

+

+ {{request.requester.handle}} {% blocktrans %} request funds worth {{amount}} {{tokenName}}{% endblocktrans %} + {{tip.created_on | naturaltime}}. +

+
+ +
+
+
+

+ {% trans "They had the following comments:" %} +

+              {{comments}}
+            
+

+
+
+ {% if not already_redeemed %} +
+ {% trans "Fund this requests" %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/app/retail/templates/emails/request_funds.txt b/app/retail/templates/emails/request_funds.txt new file mode 100644 index 00000000000..31d4df4df1e --- /dev/null +++ b/app/retail/templates/emails/request_funds.txt @@ -0,0 +1,13 @@ +{% load humanize i18n %} +🕑️ {% trans "Request funds" %} 🕑️ + +{{request.requester.handle}} {% blocktrans %} request funds worth {{amount}} {{tokenName}}{% endblocktrans %} + +{% if comments %} +The request had the following comments: +{{comments}} +{% endif %} + +{% if not already_redeemed %} +Click here to redeem it: {{link}} +{% endif %} \ No newline at end of file From 8dbcf97920ae33b2086fb1ba1c566db9220b3a28 Mon Sep 17 00:00:00 2001 From: Dan Lipert Date: Tue, 7 Apr 2020 22:03:32 +0900 Subject: [PATCH 2/2] fix style --- app/assets/onepager/js/request.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/onepager/js/request.js b/app/assets/onepager/js/request.js index e932db248d0..513769a6971 100644 --- a/app/assets/onepager/js/request.js +++ b/app/assets/onepager/js/request.js @@ -75,9 +75,9 @@ function requestFunds(username, amount, comments, tokenAddress, tokenName, succe body: formData }).then(function(json) { _alert('The funder has been notified', 'success'); - success_callback() - }).catch(function (error) { + success_callback(); + }).catch(function(error) { _alert('Something goes wrong, try later.', 'error'); - failure_callback() + failure_callback(); }); }