diff --git a/app/assets/v2/css/grants/profile.css b/app/assets/v2/css/grants/profile.css index a567afaa55a..36e2662b55c 100644 --- a/app/assets/v2/css/grants/profile.css +++ b/app/assets/v2/css/grants/profile.css @@ -71,4 +71,20 @@ h2.title { padding-left: 10px; } -} \ No newline at end of file +} + +.verification__warning { + background-color: #f8f8f0; + border: 2px solid #ffce08; + color: #b88b16; + font-weight: bold; +} + +.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/0078_auto_20200914_1945.py b/app/grants/migrations/0078_auto_20200914_1945.py new file mode 100644 index 00000000000..0bb29179140 --- /dev/null +++ b/app/grants/migrations/0078_auto_20200914_1945.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.4 on 2020-09-14 19:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dashboard', '0148_add_brightid_status'), + ('grants', '0077_grant_funding_info'), + ] + + 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 9fe3909451e..05e4a21d6cb 100644 --- a/app/grants/models.py +++ b/app/grants/models.py @@ -366,6 +366,9 @@ class Meta: # Grant Query Set used as manager. objects = GrantQuerySet.as_manager() + 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') def __str__(self): """Return the string representation of a Grant.""" 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 825c13f9b77..ea36fbb5279 100644 --- a/app/grants/templates/grants/detail/index.html +++ b/app/grants/templates/grants/detail/index.html @@ -69,6 +69,48 @@ {% include 'grants/detail/side-cart.html' %}
+ + {% include 'shared/current_profile.html' %} {% include 'shared/bottom_notification.html' %} @@ -77,7 +119,6 @@ {% include 'shared/footer.html' %} {% include 'grants/shared/shared_scripts.html' %} - {% include 'shared/activity_scripts.html' %} @@ -121,8 +162,35 @@ + $('#twitterVerification').on('click', async () => { + const response = await fetchData('/grants/v1/api/{{grant.id}}/verify'); + + if (!response.ok) { + _alert(response.msg, 'error'); + return; + } + if (response.verified) { + _alert('Congratulations, your grant is now verified!', 'success') + $('.verification__warning').remove(); + $('#startTwitterVerification .close').click() + } + if (!response.has_text) { + $('#validation-errors').text(`Don't remove the intent "{{ verification_tweet }}"`); + return; + } + + if (!response.has_code) { + $('#validation-errors').text(`Missing emoji code "{{ user_code }}", please don't remove this unique code before validate your grant.`); + return; + } + }); + + + $('#triggerTwitter').on('click', async () => { + $("#startVerification .close").click(); + }); + }); + 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..dcaa56c8a39 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 random, 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,11 @@ 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 b7c81b66af2..a449cff4605 100644 --- a/app/grants/views.py +++ b/app/grants/views.py @@ -45,8 +45,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_SECRET, TWITTER_ACCESS_SECRET, TWITTER_ACCESS_TOKEN, \ + TWITTER_CONSUMER_KEY from app.utils import get_profile from bs4 import BeautifulSoup from cacheops import cached_view @@ -61,7 +63,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, get_user_code, emoji_codes from inbox.utils import send_notification_to_user_from_gitcoinbot from kudos.models import BulkTransferCoupon, Token from marketing.mails import ( @@ -349,7 +351,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': { @@ -861,6 +864,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': @@ -1728,3 +1733,73 @@ 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