diff --git a/app/assets/v2/css/grants/profile.css b/app/assets/v2/css/grants/profile.css index a567afaa55a..26717e09265 100644 --- a/app/assets/v2/css/grants/profile.css +++ b/app/assets/v2/css/grants/profile.css @@ -70,5 +70,20 @@ h2.title { .title { padding-left: 10px; } +} + +.verification__warning { + background-color: #f8f8f0; + border: 2px solid #ffce08; + color: #b88b16; + font-weight: bold; +} -} \ No newline at end of file +.verification__warning__icon { + font-size: 1.8rem; + margin-top: 7px; +} + +.error { + color: var(--gc-pink); +} diff --git a/app/grants/admin.py b/app/grants/admin.py index 9341ea0fe18..1edf802317a 100644 --- a/app/grants/admin.py +++ b/app/grants/admin.py @@ -95,7 +95,7 @@ class GrantAdmin(GeneralAdmin): 'subscriptions_links', 'contributions_links', 'logo', 'logo_svg', 'image_css', 'link', 'clr_prediction_curve', 'hidden', 'grant_type', 'next_clr_calc_date', 'last_clr_calc_date', 'metadata', 'categories', 'twitter_handle_1', 'twitter_handle_2', 'view_count', 'is_clr_eligible', 'in_active_clrs', - 'last_update', 'funding_info' + 'last_update', 'funding_info', 'twitter_verified', 'twitter_verified_by', 'twitter_verified_at' ] readonly_fields = [ 'logo_svg_asset', 'logo_asset', diff --git a/app/grants/migrations/0080_auto_20200914_2146.py b/app/grants/migrations/0080_auto_20200914_2146.py new file mode 100644 index 00000000000..4b02eb1674b --- /dev/null +++ b/app/grants/migrations/0080_auto_20200914_2146.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.4 on 2020-09-14 21:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0148_add_brightid_status'), + ('grants', '0079_auto_20200914_2031'), + ] + + operations = [ + migrations.AddField( + model_name='grant', + name='twitter_verified', + field=models.BooleanField(default=False, help_text='The owner grant has verified the twitter account'), + ), + migrations.AddField( + model_name='grant', + name='twitter_verified_at', + field=models.DateTimeField(blank=True, help_text='At what time and date what verified this grant', null=True), + ), + migrations.AddField( + model_name='grant', + name='twitter_verified_by', + field=models.ForeignKey(blank=True, help_text='Team member who verified this grant', null=True, on_delete=django.db.models.deletion.SET_NULL, to='dashboard.Profile'), + ), + ] diff --git a/app/grants/models.py b/app/grants/models.py index 3a4f229e90b..4249a0631bf 100644 --- a/app/grants/models.py +++ b/app/grants/models.py @@ -371,6 +371,10 @@ class Meta: is_clr_active = models.BooleanField(default=False, help_text=_('CLR Round active or not? (auto computed)')) clr_round_num = models.CharField(default='', max_length=255, help_text=_('the CLR round number thats active'), blank=True) + twitter_verified = models.BooleanField(default=False, help_text='The owner grant has verified the twitter account') + twitter_verified_by = models.ForeignKey('dashboard.Profile', null=True, blank=True, on_delete=models.SET_NULL, help_text='Team member who verified this grant') + twitter_verified_at = models.DateTimeField(blank=True, null=True, help_text='At what time and date what verified this grant') + # Grant Query Set used as manager. objects = GrantQuerySet.as_manager() @@ -1259,38 +1263,38 @@ def update_tx_status(self): # We use the transaction hashes of this object to help identify zkSync checkouts. This # works as follows: - # + # # self.split_tx_id holds one of: - # Case 1: The tx hash of an L1 transaction to the BulkCheckout contract for an + # Case 1: The tx hash of an L1 transaction to the BulkCheckout contract for an # ordinary checkout # Case 2: The tx hash of an L1 transaction that deposits funds into zkSync. This # occurs when a user did not have existing funds in zkSync # Case 3: The address of the Gitcoin zkSync wallet that executed the donations. This # occurs when a user already had funds in zkSync - # + # # Case 1 has already been handled by everything above. For Case 2, we mark a # contribution as cleared once both of the below conditions are met: # 1. The L1 deposit transaction has been confirmed, and # 2. The L2 transfers have been completed # # For case 3, we mark a contribution as cleared once the L2 transfers are completed. - + # Prepare web3 provider network = self.subscription.network PROVIDER = "wss://" + network + ".infura.io/ws/v3/" + settings.INFURA_V3_PROJECT_ID w3 = Web3(Web3.WebsocketProvider(PROVIDER)) - + # Get case number is_split_tx_id_address = len(self.split_tx_id) == 42 and self.split_tx_id[0:2] == '0x' if is_split_tx_id_address: case_number = 3 - + else: # Figure out if we are in Case 1 or Case 2 # handle replace of split_tx_id if not self.split_tx_id: return - + split_tx_status, _ = get_tx_status(self.split_tx_id, self.subscription.network, self.created_on) if split_tx_status in ['pending', 'dropped', 'unknown', '']: new_tx = getReplacedTX(self.split_tx_id) @@ -1311,7 +1315,7 @@ def update_tx_status(self): batch_zksync_deposit_contract_addr = '0x9D37F793E5eD4EbD66d62D505684CD9f756504F6'.lower() zkSync_recipients = [zksync_contract_addr.lower(), batch_zksync_deposit_contract_addr.lower()] case_number = 2 if recipient_L1 in zkSync_recipients else 1 - + # If case 1, proceed as normal if case_number == 1: # actually validate token transfers diff --git a/app/grants/templates/grants/components/card.html b/app/grants/templates/grants/components/card.html index 56b041abda5..0ecb957c8b4 100644 --- a/app/grants/templates/grants/components/card.html +++ b/app/grants/templates/grants/components/card.html @@ -26,6 +26,12 @@

[[ grant.title.slice(0,60) ]][[ grant.title.length > 60 ? '...' : '']] +

@@ -121,6 +127,12 @@

[[ grant.title.slice(0,60) ]][[ grant.title.length > 60 ? '...' : '']] +

[[ grant.description.slice(0, 145) ]][[ grant.description.length > 145 ? '...' : '']]

diff --git a/app/grants/templates/grants/detail/funding.html b/app/grants/templates/grants/detail/funding.html index 7dd553de569..06c4b9dbd0a 100644 --- a/app/grants/templates/grants/detail/funding.html +++ b/app/grants/templates/grants/detail/funding.html @@ -17,7 +17,22 @@ {% load static humanize i18n grants_extra %} -

+
+ {% if not grant.twitter_verified %} +
+
+ +
+
Warning: This grant has not verified their ownership of the property listed
+ {% if is_team_member %} +
+ +
+ {% endif %} +
+
+
+ {% endif %}
diff --git a/app/grants/templates/grants/detail/index.html b/app/grants/templates/grants/detail/index.html index 2980e300b87..394856f7bd1 100644 --- a/app/grants/templates/grants/detail/index.html +++ b/app/grants/templates/grants/detail/index.html @@ -68,6 +68,48 @@ +
+ + {% include 'shared/current_profile.html' %} @@ -121,6 +163,35 @@ diff --git a/app/grants/templates/grants/detail/info.html b/app/grants/templates/grants/detail/info.html index 57c2c62e757..00aa0095713 100644 --- a/app/grants/templates/grants/detail/info.html +++ b/app/grants/templates/grants/detail/info.html @@ -98,6 +98,12 @@

{{grant.twitter_handle_1}} +

{% endif %} diff --git a/app/grants/urls.py b/app/grants/urls.py index ce3c0c2c9c4..fba18c22bc0 100644 --- a/app/grants/urls.py +++ b/app/grants/urls.py @@ -23,7 +23,7 @@ bulk_fund, flag, get_grants, get_replaced_tx, 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, toggle_grant_favorite, zksync_get_interrupt_status, zksync_set_interrupt_status, + subscription_cancel, toggle_grant_favorite, zksync_get_interrupt_status, zksync_set_interrupt_status, verify_grant, ) app_name = 'grants' @@ -65,5 +65,5 @@ path('', grants_by_grant_type, name='grants_by_category2'), path('/', grants_by_grant_type, name='grants_by_category'), path('v1/api/clr', grants_clr, name='grants_clr'), - + path('v1/api//verify', verify_grant, name='verify_grant') ] diff --git a/app/grants/utils.py b/app/grants/utils.py index 5bcae26a5b0..aa55cb15f8d 100644 --- a/app/grants/utils.py +++ b/app/grants/utils.py @@ -20,6 +20,7 @@ import logging import os from decimal import Decimal +from random import randint, seed from secrets import token_hex from economy.utils import ConversionRateNotFoundError, convert_amount @@ -28,6 +29,10 @@ logger = logging.getLogger(__name__) +block_codes = ('▖', '▗', '▘', '▙', '▚', '▛', '▜', '▝', '▞', '▟') +emoji_codes = ('🎉', '🎈', '🎁', '🎊', '🙌', '🥂', '🎆', '🔥', '⚡', '👍') + + def get_upload_filename(instance, filename): salt = token_hex(16) file_path = os.path.basename(filename) @@ -115,10 +120,10 @@ def which_clr_round(timestamp): if round_start < timestamp < round_end: return round - + return None -def get_converted_amount(amount, token_symbol): +def get_converted_amount(amount, token_symbol): try: if token_symbol == "ETH" or token_symbol == "WETH": return Decimal(float(amount) * float(eth_usd_conv_rate())) @@ -142,3 +147,10 @@ def get_converted_amount(amount, token_symbol): except ConversionRateNotFoundError as no_conversion_e: logger.info(no_conversion_e) return None + + +def get_user_code(user_id, grant, coding_set=block_codes, length=6): + seed(user_id ** grant.id) + coding_id = [coding_set[randint(0, 9)] for _ in range(length)] + + return ''.join(coding_id) diff --git a/app/grants/views.py b/app/grants/views.py index a0b9f44d375..beb96a8e46e 100644 --- a/app/grants/views.py +++ b/app/grants/views.py @@ -46,8 +46,10 @@ from django.views.decorators.http import require_GET import requests +import tweepy from app.services import RedisService -from app.settings import EMAIL_ACCOUNT_VALIDATION +from app.settings import EMAIL_ACCOUNT_VALIDATION, TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET, TWITTER_ACCESS_SECRET, \ + TWITTER_ACCESS_TOKEN from app.utils import get_profile from bs4 import BeautifulSoup from cacheops import cached_view @@ -62,7 +64,7 @@ CartActivity, Contribution, Flag, Grant, GrantCategory, GrantCLR, GrantType, MatchPledge, PhantomFunding, Subscription, ) -from grants.utils import get_leaderboard, is_grant_team_member +from grants.utils import get_leaderboard, is_grant_team_member, emoji_codes, get_user_code from inbox.utils import send_notification_to_user_from_gitcoinbot from kudos.models import BulkTransferCoupon, Token from marketing.mails import ( @@ -350,7 +352,8 @@ def get_grants(request): 'token_symbol': grant.token_symbol, 'admin_address': grant.admin_address, 'token_address': grant.token_address, - 'image_css': grant.image_css + 'image_css': grant.image_css, + 'verified': grant.twitter_verified, } for grant in grants }, 'credentials': { @@ -860,6 +863,8 @@ def grant_details(request, grant_id, grant_slug): 'is_unsubscribed_from_updates_from_this_grant': is_unsubscribed_from_updates_from_this_grant, 'is_round_5_5': False, 'options': [(f'Email Grant Funders ({grant.contributor_count})', 'bullhorn', 'Select this option to email your status update to all your funders.')] if is_team_member else [], + 'user_code': get_user_code(request.user.profile.id, grant, emoji_codes) if request.user.is_authenticated else '', + 'verification_tweet': get_grant_verification_text(grant), } if tab == 'stats': @@ -1727,3 +1732,72 @@ def toggle_grant_favorite(request, grant_id): return JsonResponse({ 'action': 'follow' }) + + +def get_grant_verification_text(grant, long=True): + msg = f'I am verifying my ownership of { grant.title } on Gitcoin Grants' + if long: + msg += f' at https://gitcoin.co{grant.get_absolute_url()}.' + return msg + + +@login_required +def verify_grant(request, grant_id): + grant = Grant.objects.get(pk=grant_id) + + if not is_grant_team_member(grant, request.user.profile): + return JsonResponse({ + 'ok': False, + 'msg': f'You need to be a member of this grants to verify it.' + }) + + if grant.twitter_verified: + return JsonResponse({ + 'ok': True, + 'msg': 'Grant was verified previously' + }) + + auth = tweepy.OAuthHandler(TWITTER_CONSUMER_KEY, TWITTER_CONSUMER_SECRET) + auth.set_access_token(TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET) + try: + api = tweepy.API(auth) + last_tweet = api.user_timeline(screen_name=grant.twitter_handle_1, count=1, tweet_mode="extended", + include_rts=False, exclude_replies=False)[0] + except tweepy.TweepError: + return JsonResponse({ + 'ok': False, + 'msg': f'Sorry, we couldn\'t get the last tweet from @{grant.twitter_handle_1}' + }) + except IndexError: + return JsonResponse({ + 'ok': False, + 'msg': 'Sorry, we couldn\'t retrieve the last tweet from your timeline' + }) + + if last_tweet.retweeted or 'RT @' in last_tweet.full_text: + return JsonResponse({ + 'ok': False, + 'msg': 'We get a retweet from your last status, at this moment we don\'t supported retweets.' + }) + + user_code = get_user_code(request.user.profile.id, grant, emoji_codes) + text = get_grant_verification_text(grant, False) + + has_code = user_code in last_tweet.full_text + has_text = text in last_tweet.full_text + + if has_code and has_text: + grant.twitter_verified = True + grant.twitter_verified_by = request.user.profile + grant.twitter_verified_at = timezone.now() + + grant.save() + + return JsonResponse({ + 'ok': True, + 'verified': grant.twitter_verified, + 'text': last_tweet.full_text, + 'has_code': has_code, + 'has_text': has_text, + 'account': grant.twitter_handle_1 + }) diff --git a/requirements/base.txt b/requirements/base.txt index 0f1590d0cbd..4409ae9389f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -101,3 +101,4 @@ twilio django-queryset-csv django-proxy==1.2.1 ed25519 +tweepy