diff --git a/app/assets/v2/js/cart.js b/app/assets/v2/js/cart.js index 6f5627924fc..9af3faafec7 100644 --- a/app/assets/v2/js/cart.js +++ b/app/assets/v2/js/cart.js @@ -652,10 +652,10 @@ Vue.component('grants-cart', { }); } else { approvalTx.send({ from: userAddress }) - .on('transactionHash', (txHash) => { // eslint-disable-line no-loop-func + .on('transactionHash', async(txHash) => { // eslint-disable-line no-loop-func indicateMetamaskPopup(true); this.setApprovalTxHash(tokenName, txHash); - this.sendDonationTx(userAddress); + await this.sendDonationTx(userAddress); }) .on('error', (error, receipt) => { // If the transaction was rejected by the network with a receipt, the second parameter will be the receipt. @@ -676,7 +676,7 @@ Vue.component('grants-cart', { }); }, - sendDonationTx(userAddress) { + async sendDonationTx(userAddress) { // Configure our donation inputs // We use parse and stringify to avoid mutating this.donationInputs since we use it later const bulkTransaction = new web3.eth.Contract(bulkCheckoutAbi, bulkCheckoutAddress); @@ -697,10 +697,10 @@ Vue.component('grants-cart', { bulkTransaction.methods .donate(donationInputsFiltered) .send({ from: userAddress, gas: this.donationInputsGasLimit, value: this.donationInputsEthAmount }) - .on('transactionHash', (txHash) => { + .on('transactionHash', async(txHash) => { console.log('Donation transaction hash: ', txHash); indicateMetamaskPopup(true); - this.postToDatabase(txHash, userAddress); + this.postToDatabase(txHash, userAddress); // add `await` here if you want to wait for a response // Clear cart, redirect back to grants page, and show success alert localStorage.setItem('contributions_were_successful', 'true'); localStorage.setItem('contributions_count', String(this.grantData.length)); @@ -726,19 +726,50 @@ Vue.component('grants-cart', { }); }, - postToDatabase(txHash, userAddress) { + async postToDatabase(txHash, userAddress) { // this.donationInputs is the array used for bulk donations // We loop through each donation and POST the required data const donations = this.donationInputs; const csrfmiddlewaretoken = document.querySelector('[name=csrfmiddlewaretoken]').value; + // Configure template payload + const saveSubscriptionPayload = { + // Values that are constant for all donations + contributor_address: userAddress, + csrfmiddlewaretoken, + frequency_count: 1, + frequency_unit: 'rounds', + gas_price: 0, + gitcoin_donation_address: gitcoinAddress, + hide_wallet_address: this.hideWalletAddress, + match_direction: '+', + network: document.web3network, + num_periods: 1, + real_period_seconds: 0, + recurring_or_not: 'once', + signature: 'onetime', + split_tx_id: txHash, // this txhash is our bulk donation hash + splitter_contract_address: bulkCheckoutAddress, + subscription_hash: 'onetime', + // Values that vary by donation + 'gitcoin-grant-input-amount': [], + admin_address: [], + amount_per_period: [], + comment: [], + confirmed: [], + contract_address: [], + contract_version: [], + denomination: [], + grant_id: [], + sub_new_approve_tx_id: [], + token_address: [], + token_symbol: [] + }; + for (let i = 0; i < donations.length; i += 1) { // Get URL to POST to const donation = donations[i]; const grantId = donation.grant.grant_id; - const grantSlug = donation.grant.grant_slug; - const url = `/grants/${grantId}/${grantSlug}/fund`; - // Get token information const tokenName = donation.grant.grant_donation_currency; @@ -758,57 +789,42 @@ Vue.component('grants-cart', { // 100 makes it easier to search the DB to find which Gitcoin donations were automatic const isAutomatic = donation.grant.isAutomatic; const gitcoinGrantInputAmt = isAutomatic ? 100 : Number(this.gitcoinFactorRaw); - var network = document.web3network; - // Configure saveSubscription payload - const saveSubscriptionPayload = new URLSearchParams({ - admin_address: donation.grant.grant_admin_address, - amount_per_period: Number(donation.grant.grant_donation_amount), - comment, - confirmed: false, - contract_address: donation.grant.grant_contract_address, - contract_version: donation.grant.grant_contract_version, - contributor_address: userAddress, - csrfmiddlewaretoken, - denomination: tokenAddress, - frequency_count: 1, - frequency_unit: 'rounds', - gas_price: 0, - 'gitcoin-grant-input-amount': gitcoinGrantInputAmt, - gitcoin_donation_address: gitcoinAddress, - grant_id: grantId, - hide_wallet_address: this.hideWalletAddress, - match_direction: '+', - network, - num_periods: 1, - real_period_seconds: 0, - recurring_or_not: 'once', - signature: 'onetime', - split_tx_id: txHash, // this txhash is our bulk donation hash - splitter_contract_address: bulkCheckoutAddress, - sub_new_approve_tx_id: donation.tokenApprovalTxHash, - subscription_hash: 'onetime', - token_address: tokenAddress, - token_symbol: tokenName - }); - - // Configure headers - const headers = { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' - }; - - // Define parameter objects for POST request - const saveSubscriptionParams = { - method: 'POST', - headers, - body: saveSubscriptionPayload - }; - // Send saveSubscription request - fetch(url, saveSubscriptionParams) - .catch(err => { - this.handleError(err); - }); - } + // Add the donation parameters + saveSubscriptionPayload.admin_address.push(donation.grant.grant_admin_address); + saveSubscriptionPayload.amount_per_period.push(Number(donation.grant.grant_donation_amount)); + saveSubscriptionPayload.comment.push(comment); + saveSubscriptionPayload.confirmed.push(false); + saveSubscriptionPayload.contract_address.push(donation.grant.grant_contract_address); + saveSubscriptionPayload.contract_version.push(donation.grant.grant_contract_version); + saveSubscriptionPayload.denomination.push(tokenAddress); + saveSubscriptionPayload['gitcoin-grant-input-amount'].push(gitcoinGrantInputAmt); + saveSubscriptionPayload.grant_id.push(grantId); + saveSubscriptionPayload.sub_new_approve_tx_id.push(donation.tokenApprovalTxHash); + saveSubscriptionPayload.token_address.push(tokenAddress); + saveSubscriptionPayload.token_symbol.push(tokenName); + } // end for each donation + + + // Configure request parameters + const url = '/grants/bulk-fund'; + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' + }; + const saveSubscriptionParams = { + method: 'POST', + headers, + body: new URLSearchParams(saveSubscriptionPayload) + }; + + // Send saveSubscription request + // check `Preserve log` in console settings to inspect these logs more easily + const res = await fetch(url, saveSubscriptionParams); + + console.log('Bulk fund POST response', res); + const json = await res.json(); + + console.log('Bulk fund POST response, JSON', json); }, sleep(ms) { diff --git a/app/grants/urls.py b/app/grants/urls.py index 8f5714c0d3b..fa639ba8e72 100644 --- a/app/grants/urls.py +++ b/app/grants/urls.py @@ -22,7 +22,7 @@ from grants.views import ( flag, grant_activity, grant_categories, grant_details, grant_fund, grant_new, grant_new_whitelabel, grants, grants_addr_as_json, grants_bulk_add, grants_by_grant_type, grants_cart_view, grants_clr, grants_stats_view, - invoice, leaderboard, new_matching_partner, profile, quickstart, subscription_cancel, + invoice, leaderboard, new_matching_partner, profile, quickstart, subscription_cancel, bulk_fund, ) app_name = 'grants' @@ -39,6 +39,7 @@ re_path(r'^new', grant_new, name='new'), re_path(r'^categories', grant_categories, name='grant_categories'), path('//fund', grant_fund, name='fund'), + path('bulk-fund', bulk_fund, name='bulk_fund'), path( '//subscription//cancel', subscription_cancel, diff --git a/app/grants/views.py b/app/grants/views.py index 290648f63ff..eacdf6feea5 100644 --- a/app/grants/views.py +++ b/app/grants/views.py @@ -63,6 +63,7 @@ support_cancellation, thank_you_for_supporting, ) from marketing.models import Keyword, Stat +from perftools.models import JSONStore from ratelimit.decorators import ratelimit from retail.helpers import get_ip from townsquare.models import Comment, PinnedPost @@ -850,7 +851,157 @@ def grant_fund(request, grant_id, grant_slug): raise Http404 +@login_required +def bulk_fund(request): + if request.method != 'POST': + raise Http404 + + # Save off payload data + JSONStore.objects.create( + key=request.POST.get('split_tx_id'), # use bulk data tx hash as key + view='bulk_fund_post_payload', + data=request.POST + ) + + # Get list of grant IDs + grant_ids_list = [int(pk) for pk in request.POST.get('grant_id').split(',')] + + # For each grant, we validate the data. If it fails, save it off and throw error at the end + successes = [] + failures = [] + for (index, grant_id) in enumerate(grant_ids_list): + try: + grant = Grant.objects.get(pk=grant_id) + except Grant.DoesNotExist: + failures.append({ + 'active': 'grant_error', + 'title': _('Fund - Grant Does Not Exist'), + 'grant':grant_id, + 'text': _('This grant does not exist'), + 'subtext': _('No grant with this ID was found'), + 'success': False + }) + continue + + profile = get_profile(request) + + if not grant.active: + failures.append({ + 'active': 'grant_error', + 'title': _('Fund - Grant Ended'), + 'grant':grant_id, + 'text': _('This Grant has ended.'), + 'subtext': _('Contributions can no longer be made this grant'), + 'success': False + }) + continue + + if is_grant_team_member(grant, profile): + failures.append({ + 'active': 'grant_error', + 'title': _('Fund - Grant funding blocked'), + 'grant':grant_id, + 'text': _('This Grant cannot be funded'), + 'subtext': _('Grant team members cannot contribute to their own grant.'), + 'success': False + }) + continue + + if grant.link_to_new_grant: + failures.append({ + 'active': 'grant_error', + 'title': _('Fund - Grant Migrated'), + 'grant':grant_id, + 'text': _('This Grant has ended'), + 'subtext': _('Contributions can no longer be made to this grant.
Visit the new grant to contribute.'), + 'success': False + }) + continue + active_subscription = Subscription.objects.select_related('grant').filter( + grant=grant_id, active=True, error=False, contributor_profile=request.user.profile, is_postive_vote=True + ) + + if active_subscription: + failures.append({ + 'active': 'grant_error', + 'title': _('Subscription Exists'), + 'grant':grant_id, + 'text': _('You already have an active subscription for this grant.'), + 'success': False + }) + continue + + if not grant.configured_to_receieve_funding: + failures.append({ + 'active': 'grant_error', + 'title': _('Fund - Grant Not Configured'), + 'grant':grant_id, + 'text': _('This Grant is not configured to accept funding at this time.'), + 'subtext': _('Grant is not properly configured for funding. Please set grant.contract_address on this grant, or contact founders@gitcoin.co if you believe this message is in error!'), + 'success': False + }) + continue + + try: + from grants.tasks import process_grant_contribution + payload = { + # Values that are constant for all donations + 'contributor_address': request.POST.get('contributor_address'), + 'csrfmiddlewaretoken': request.POST.get('csrfmiddlewaretoken'), + 'frequency_count': request.POST.get('frequency_count'), + 'frequency_unit': request.POST.get('frequency_unit'), + 'gas_price': request.POST.get('gas_price'), + 'gitcoin_donation_address': request.POST.get('gitcoin_donation_address'), + 'hide_wallet_address': request.POST.get('hide_wallet_address'), + 'match_direction': request.POST.get('match_direction'), + 'network': request.POST.get('network'), + 'num_periods': request.POST.get('num_periods'), + 'real_period_seconds': request.POST.get('real_period_seconds'), + 'recurring_or_not': request.POST.get('recurring_or_not'), + 'signature': request.POST.get('signature'), + 'split_tx_id': request.POST.get('split_tx_id'), + 'splitter_contract_address': request.POST.get('splitter_contract_address'), + 'subscription_hash': request.POST.get('subscription_hash'), + # Values that vary by donation + 'admin_address': request.POST.get('admin_address').split(',')[index], + 'amount_per_period': request.POST.get('amount_per_period').split(',')[index], + 'comment': request.POST.get('comment').split(',')[index], + 'confirmed': request.POST.get('confirmed').split(',')[index], + 'contract_address': request.POST.get('contract_address').split(',')[index], + 'contract_version': request.POST.get('contract_version').split(',')[index], + 'denomination': request.POST.get('denomination').split(',')[index], + 'gitcoin-grant-input-amount': request.POST.get('gitcoin-grant-input-amount').split(',')[index], + 'grant_id': request.POST.get('grant_id').split(',')[index], + 'sub_new_approve_tx_id': request.POST.get('sub_new_approve_tx_id').split(',')[index], + 'token_address': request.POST.get('token_address').split(',')[index], + 'token_symbol': request.POST.get('token_symbol').split(',')[index], + } + process_grant_contribution.delay(grant_id, grant.slug, profile.pk, payload) + except Exception as e: + failures.append({ + 'active': 'grant_error', + 'title': _('Fund - Grant Processing Failed'), + 'grant':grant_id, + 'text': _('This Grant was not processed successfully.'), + 'subtext': _(f'{str(e)}'), + 'success': False + }) + continue + + successes.append({ + 'title': _('Fund - Grant Funding Processed Successfully'), + 'grant':grant_id, + 'text': _('Funding for this grant was successfully processed and saved.'), + 'success': True + }) + + return JsonResponse({ + 'success': True, + 'grant_ids': grant_ids_list, + 'successes': successes, + 'failures': failures + }) @login_required def subscription_cancel(request, grant_id, grant_slug, subscription_id):