From 137f73e9bb5d84a263420afea33d5bba03c73344 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Sun, 25 Oct 2020 16:52:54 -0500 Subject: [PATCH 01/10] google auth flow --- docs/integrations.md | 11 +++++++++ integrations/__init__.py | 0 integrations/admin.py | 3 +++ integrations/apps.py | 5 ++++ integrations/migrations/__init__.py | 0 integrations/models.py | 3 +++ integrations/tests.py | 3 +++ integrations/urls.py | 7 ++++++ integrations/views.py | 37 +++++++++++++++++++++++++++++ penny_university/settings/base.py | 9 +++++++ penny_university/urls.py | 1 + requirements.txt | 17 +++++++------ 12 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 docs/integrations.md create mode 100644 integrations/__init__.py create mode 100644 integrations/admin.py create mode 100644 integrations/apps.py create mode 100644 integrations/migrations/__init__.py create mode 100644 integrations/models.py create mode 100644 integrations/tests.py create mode 100644 integrations/urls.py create mode 100644 integrations/views.py diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 00000000..e03d4d4d --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,11 @@ +Go to https://developers.google.com/calendar/quickstart/python?authuser=1 and enable the calendar API in order to get credentials for your dev account + +Use: http://localhost:8000/integrations/google/auth-success for your redirect uri + +Copy credentials to these respective settings: +* export GOOGLE_CLIENT_ID +* export GOOGLE_CLIENT_SECRET +* export GOOGLE_REDIRECT_URI + +Scopes = 'https://www.googleapis.com/auth/calendar.events' +Access type = offline diff --git a/integrations/__init__.py b/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integrations/admin.py b/integrations/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/integrations/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/integrations/apps.py b/integrations/apps.py new file mode 100644 index 00000000..bb57e755 --- /dev/null +++ b/integrations/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class IntegrationsConfig(AppConfig): + name = 'integrations' diff --git a/integrations/migrations/__init__.py b/integrations/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/integrations/models.py b/integrations/models.py new file mode 100644 index 00000000..71a83623 --- /dev/null +++ b/integrations/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/integrations/tests.py b/integrations/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/integrations/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/integrations/urls.py b/integrations/urls.py new file mode 100644 index 00000000..4a172a6b --- /dev/null +++ b/integrations/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('google/auth-request/', views.auth_request, name='google-auth-request'), + path('google/auth-success/', views.auth_success, name='google-auth-success'), +] diff --git a/integrations/views.py b/integrations/views.py new file mode 100644 index 00000000..51789209 --- /dev/null +++ b/integrations/views.py @@ -0,0 +1,37 @@ +from django.http import HttpResponse +from django.shortcuts import redirect +from django.conf import settings +import google_auth_oauthlib.flow + + +def auth_request(request): + client_secrets = { + "web": { + "client_id": settings.GOOGLE_AUTH['CLIENT_ID'], + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "client_secret": settings.GOOGLE_AUTH['CLIENT_SECRET'], + "redirect_uris": [settings.GOOGLE_AUTH['REDIRECT_URI']] + } + } + + flow = google_auth_oauthlib.flow.Flow.from_client_config( + client_secrets, + scopes=['https://www.googleapis.com/auth/calendar.events'], + ) + + flow.redirect_uri = settings.GOOGLE_AUTH['REDIRECT_URI'] + + authorization_url, state = flow.authorization_url( + # Enable offline access so that you can refresh an access token without + # re-prompting the user for permission. Recommended for web server apps. + access_type='offline', + # Enable incremental authorization. Recommended as a best practice. + include_granted_scopes='true', + ) + + return redirect(authorization_url) + + +def auth_success(request): + return HttpResponse("

Yay, you authorized G Cal!

") diff --git a/penny_university/settings/base.py b/penny_university/settings/base.py index 030337ef..ed1c7bca 100644 --- a/penny_university/settings/base.py +++ b/penny_university/settings/base.py @@ -49,6 +49,7 @@ 'bot', 'api', 'home', + 'integrations', 'pennychat', 'matchmaking', 'users', @@ -208,3 +209,11 @@ # Background Tasks CHAT_REMINDER_BEFORE_PENNY_CHAT_MINUTES = 75 # extra 15 minutes to make sure we remind them MORE than an hour in advance FOLLOWUP_REMINDER_AFTER_PENNY_CHAT_MINUTES = 30 + + +# Google OAuth Settings +GOOGLE_AUTH = { + 'CLIENT_ID': os.environ.get('GOOGLE_CLIENT_ID'), + 'CLIENT_SECRET': os.environ.get('GOOGLE_CLIENT_SECRET'), + 'REDIRECT_URI': os.environ.get('GOOGLE_REDIRECT_URI'), +} diff --git a/penny_university/urls.py b/penny_university/urls.py index f3cdab74..dd9928ec 100644 --- a/penny_university/urls.py +++ b/penny_university/urls.py @@ -20,5 +20,6 @@ path('admin/', admin.site.urls), path('bot/', include('bot.urls')), path('api/', include('api.urls')), + path('integrations/', include('integrations.urls')), path('', include('home.urls')) ] diff --git a/requirements.txt b/requirements.txt index 78551be5..36a68bde 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,21 @@ django==2.2.13 -dj-database-url==0.5.0 django-background-tasks==1.2.5 +django-cors-headers>=3.2.1 +django-filter==2.2.0 +djangorestframework>=3.10.3 +dj-database-url==0.5.0 +dj-rest-auth==1.0.1 +google-api-python-client==1.12.4 +google-auth-httplib2==0.0.4 +google-auth-oauthlib==0.4.1 gunicorn==19.9.0 python-dateutil==2.8.1 +requests==2.22.0 sentry-sdk==0.14.4 slackclient>=2.2.0 -requests==2.22.0 +urllib3==1.25.9 uwsgi==2.0.18 whitenoise==4.1.4 -djangorestframework>=3.10.3 -django-cors-headers>=3.2.1 -dj-rest-auth==1.0.1 -django-filter==2.2.0 -urllib3==1.25.9 # I had a little trouble installing psycopg2, this finally worked # brew install postgres From 0ab0f574216adcbe5f24deba0064d6bace8581db Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Sun, 15 Nov 2020 11:45:22 -0600 Subject: [PATCH 02/10] Integrate w/Google via Slack --- .gitignore | 1 + bot/processors/pennychat.py | 72 ++++++++++- bot/tasks/pennychat.py | 70 +++++++---- bot/utils.py | 33 +++++ bot/views.py | 2 + integrations/google.py | 114 ++++++++++++++++++ integrations/migrations/0001_initial.py | 34 ++++++ integrations/models.py | 13 +- integrations/serializers.py | 19 +++ integrations/urls.py | 1 + integrations/views.py | 57 +++++---- .../0004_pennychat_video_conference_link.py | 18 +++ pennychat/models.py | 1 + requirements.txt | 1 + 14 files changed, 378 insertions(+), 58 deletions(-) create mode 100644 integrations/google.py create mode 100644 integrations/migrations/0001_initial.py create mode 100644 integrations/serializers.py create mode 100644 pennychat/migrations/0004_pennychat_video_conference_link.py diff --git a/.gitignore b/.gitignore index b02e0d9c..26e47449 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ db.sqlite3 venv staticfiles/* !staticfiles/.gitignore +.env .envrc .direnv diff --git a/bot/processors/pennychat.py b/bot/processors/pennychat.py index 7d14beb6..0d55c4bb 100644 --- a/bot/processors/pennychat.py +++ b/bot/processors/pennychat.py @@ -9,7 +9,9 @@ post_organizer_edit_after_share_blocks, share_penny_chat_invitation, ) -from bot.utils import chat_postEphemeral_with_fallback +from bot.utils import chat_postEphemeral_with_fallback, build_share_string +from integrations.google import build_credentials, GoogleCalendar, get_authorization_url +from integrations.models import GoogleCredentials from pennychat.models import ( PennyChat, PennyChatSlackInvitation, @@ -26,11 +28,13 @@ VIEW_SUBMISSION = 'view_submission' VIEW_CLOSED = 'view_closed' +ADD_GOOGLE_INTEGRATION = 'add_google_integration' PENNY_CHAT_DATE = 'penny_chat_date' PENNY_CHAT_TIME = 'penny_chat_time' PENNY_CHAT_USER_SELECT = 'penny_chat_user_select' PENNY_CHAT_CHANNEL_SELECT = 'penny_chat_channel_select' PENNY_CHAT_DETAILS = 'penny_chat_details' +PENNY_CHAT_REVIEW_DETAILS = 'penny_chat_review_details' PENNY_CHAT_EDIT = 'penny_chat_edit' PENNY_CHAT_SHARE = 'penny_chat_share' PENNY_CHAT_CAN_ATTEND = 'penny_chat_can_attend' @@ -85,7 +89,7 @@ def penny_chat_details_modal(penny_chat_invitation): }, 'submit': { 'type': 'plain_text', - 'text': 'Share Invite :the_horns:', + 'text': 'Submit :floppy_disk:', }, 'blocks': [ { @@ -231,6 +235,40 @@ def penny_chat_details_modal(penny_chat_invitation): return template +def add_google_integration_modal(authorization_url): + template = { + 'type': 'modal', + 'notify_on_close': True, + 'callback_id': "google_auth_callback", + 'title': { + 'type': 'plain_text', + 'text': 'Penny Chat Details' + }, + 'blocks': [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Click the button to activate the Google Calendar integration." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Add Google Integration", + "emoji": True + }, + "value": "add_integration", + "url": authorization_url, + "action_id": ADD_GOOGLE_INTEGRATION + } + } + ] + } + + return template + + class PennyChatBotModule(BotModule): """Responsible for all interactions related to the `/penny chat` command. @@ -302,6 +340,12 @@ def create_penny_chat(cls, slack, event): penny_chat_invitation.view = response.data['view']['id'] penny_chat_invitation.save() + @classmethod + def integrate_google_calendar(cls, slack, event): + user = get_or_create_social_profile_from_slack_id(event['user_id']) + modal = add_google_integration_modal(authorization_url=get_authorization_url(user.email)) + slack.views_open(view=modal, trigger_id=event['trigger_id']) + @is_block_interaction_event @has_action_id(PENNY_CHAT_SCHEDULE_MATCH) def schedule_match(self, event): @@ -379,6 +423,18 @@ def visibility_select(self, event): penny_chat_invitation.visibility = int(selected_visibility) penny_chat_invitation.save() + def add_google_meet(self, penny_chat_invitation): + user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user + google_credentials = GoogleCredentials.objects.get(user=user) + credentials = build_credentials(google_credentials) + calendar = GoogleCalendar(credentials=credentials) + + return calendar.create_event( + summary=penny_chat_invitation.title, + description=penny_chat_invitation.description, + start=penny_chat_invitation.date + ) + @has_event_type([VIEW_SUBMISSION, VIEW_CLOSED]) @has_callback_id(PENNY_CHAT_DETAILS) def submit_details_and_share(self, event): @@ -403,6 +459,18 @@ def submit_details_and_share(self, event): } } + penny_chat_invitation.save_organizer_from_slack_id(penny_chat_invitation.organizer_slack_id) + + user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user + + if user.google_credentials is None: + update_modal = add_google_integration_modal(view['id']) + response = self.slack_client.views_push(view=update_modal, trigger_id=event['trigger_id']) + + if not penny_chat_invitation.video_conference_link: + meet = self.add_google_meet(penny_chat_invitation) + penny_chat_invitation.video_conference_link = meet['hangoutLink'] + # Ready to share penny_chat_invitation.status = PennyChatSlackInvitation.SHARED penny_chat_invitation.save() diff --git a/bot/tasks/pennychat.py b/bot/tasks/pennychat.py index 477765df..9a780bbe 100644 --- a/bot/tasks/pennychat.py +++ b/bot/tasks/pennychat.py @@ -8,12 +8,12 @@ from pytz import timezone, utc from sentry_sdk import capture_exception +from bot.utils import build_share_string, comma_split from common.utils import get_slack_client from pennychat.models import PennyChatSlackInvitation, Participant from users.models import ( SocialProfile, get_or_create_social_profile_from_slack_id, - get_or_create_social_profile_from_slack_ids, ) VIEW_SUBMISSION = 'view_submission' @@ -197,11 +197,13 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): if include_calendar_link: start_date = penny_chat_invitation.date.astimezone(utc).strftime('%Y%m%dT%H%M%SZ') end_date = (penny_chat_invitation.date.astimezone(utc) + timedelta(hours=1)).strftime('%Y%m%dT%H%M%SZ') + + description = f'{penny_chat_invitation.description} [Video Link]({penny_chat_invitation.video_conference_link})' google_cal_url = 'https://calendar.google.com/calendar/render?' \ 'action=TEMPLATE&text=' \ f'{urllib.parse.quote(penny_chat_invitation.title)}&dates=' \ f'{start_date}/{end_date}&details=' \ - f'{urllib.parse.quote(penny_chat_invitation.description)}' + f'{urllib.parse.quote(description)}' date_time_block['accessory'] = { 'type': 'button', @@ -240,6 +242,45 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): date_time_block ] + if penny_chat_invitation.video_conference_link: + body.append( + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': f'*Video Call Link*' + } + } + ) + if mode in {PREVIEW, INVITE, UPDATE}: + body.append( + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': f'A video link will be provided shortly before the chat starts' + } + } + ) + elif mode in {REMIND}: + body.append( + { + 'type': 'actions', + 'elements': [ + { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Join Video Call :call_me_hand:', + 'emoji': True, + }, + 'url': penny_chat_invitation.video_conference_link, + 'style': 'primary', + } + ] + } + ) + if include_rsvp: body.append( { @@ -368,25 +409,7 @@ def _followup_reminder_blocks(penny_chat_invitation): def organizer_edit_after_share_blocks(slack_client, penny_chat_invitation): - shares = [] - users = get_or_create_social_profile_from_slack_ids( - comma_split(penny_chat_invitation.invitees), - slack_client=slack_client, - ) - for slack_user_id in comma_split(penny_chat_invitation.invitees): - shares.append(users[slack_user_id].real_name) - - if len(penny_chat_invitation.channels) > 0: - for channel in comma_split(penny_chat_invitation.channels): - shares.append(f'<#{channel}>') - - if len(shares) == 1: - share_string = shares[0] - elif len(shares) == 2: - share_string = ' and '.join(shares) - elif len(shares) > 2: - shares[-1] = f'and {shares[-1]}' - share_string = ', '.join(shares) + share_string = build_share_string(slack_client, penny_chat_invitation) shared_message_preview_blocks = _penny_chat_details_blocks(penny_chat_invitation, mode=PREVIEW) + [ { @@ -422,8 +445,3 @@ def organizer_edit_after_share_blocks(slack_client, penny_chat_invitation): ] return shared_message_preview_blocks - - -def comma_split(comma_delimited_string): - """normal string split for ''.split(',') returns [''], so using this instead""" - return [x for x in comma_delimited_string.split(',') if x] diff --git a/bot/utils.py b/bot/utils.py index 00c2a068..2f24b56e 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -1,10 +1,13 @@ import logging from django.conf import settings + from common.utils import get_slack_client from sentry_sdk import capture_exception from slack.errors import SlackApiError +from users.models import get_or_create_social_profile_from_slack_ids + _CHANNEL_NAME__ID = None @@ -27,6 +30,36 @@ def notify_admins(slack_client, message): pass +def comma_split(comma_delimited_string): + """normal string split for ''.split(',') returns [''], so using this instead""" + return [x for x in comma_delimited_string.split(',') if x] + + +def build_share_string(slack_client, penny_chat_invitation): + shares = [] + users = get_or_create_social_profile_from_slack_ids( + comma_split(penny_chat_invitation.invitees), + slack_client=slack_client, + ) + for slack_user_id in comma_split(penny_chat_invitation.invitees): + shares.append(users[slack_user_id].real_name) + + if len(penny_chat_invitation.channels) > 0: + for channel in comma_split(penny_chat_invitation.channels): + shares.append(f'<#{channel}>') + + share_string = '' + if len(shares) == 1: + share_string = shares[0] + elif len(shares) == 2: + share_string = ' and '.join(shares) + elif len(shares) > 2: + shares[-1] = f'and {shares[-1]}' + share_string = ', '.join(shares) + + return share_string + + def chat_postEphemeral_with_fallback(slack_client, channel, user, blocks=None, text=None): try: slack_client.chat_postEphemeral(channel=channel, user=user, blocks=blocks, text=text) diff --git a/bot/views.py b/bot/views.py index bb01961e..7394514a 100644 --- a/bot/views.py +++ b/bot/views.py @@ -66,6 +66,8 @@ def command(request): command = event['text'].split(' ', 1)[0] if command == 'chat': PennyChatBotModule.create_penny_chat(slack_client, event) + elif command == 'gcal': + PennyChatBotModule.integrate_google_calendar(slack_client, event) elif command == 'set-topic': MatchMakingBotModule.set_topic_channel(slack_client, event) else: diff --git a/integrations/google.py b/integrations/google.py new file mode 100644 index 00000000..2ccc044a --- /dev/null +++ b/integrations/google.py @@ -0,0 +1,114 @@ +import random +import string +from datetime import timedelta + +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +from google_auth_oauthlib.flow import Flow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from google.oauth2.credentials import Credentials + +from django.conf import settings + + +class GoogleCalendar: + def __init__(self, credentials, calendar_id='primary'): + self.service = build('calendar', 'v3', credentials=credentials) + self.calendar_id = calendar_id + + @property + def events(self): + return self.service.events() + + def add_conference_call_to_event(self, event_id): + event_patch = { + 'conferenceData': { + 'createRequest': { + 'requestId': random_string_generator() + } + } + } + self.events.patch( + calendarId=self.calendar_id, + eventId=event_id, + body=event_patch, + conferenceDataVersion=1, + ).execute() + + # Fetch updated event and return it + return self.events.get(calendarId=self.calendar_id, eventId=event_id).execute() + + def create_event(self, summary, description, start, end=None, with_meet=True): + if not end: + end = start + timedelta(hours=1) + data = { + 'summary': summary, + 'description': description, + 'start': { + 'dateTime': start.isoformat(), + }, + 'end': { + 'dateTime': end.isoformat(), + } + } + try: + event_data = self.events.insert(calendarId='primary', body=data).execute() + + if with_meet: + event_data = self.add_conference_call_to_event(event_id=event_data['id']) + except HttpError as e: + print(e) + + return event_data + + +def get_google_flow(): + client_secrets = { + "web": { + "client_id": settings.GOOGLE_AUTH['CLIENT_ID'], + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "client_secret": settings.GOOGLE_AUTH['CLIENT_SECRET'], + "redirect_uris": [settings.GOOGLE_AUTH['REDIRECT_URI']] + } + } + + flow = Flow.from_client_config( + client_secrets, + scopes=['https://www.googleapis.com/auth/calendar.events'], + ) + + flow.redirect_uri = settings.GOOGLE_AUTH['REDIRECT_URI'] + + return flow + + +def get_authorization_url(user_email): + flow = get_google_flow() + + authorization_url, state = flow.authorization_url( + # Enable offline access so that you can refresh an access token without + # re-prompting the user for permission. Recommended for web server apps. + access_type='offline', + # Enable incremental authorization. Recommended as a best practice. + include_granted_scopes='true', + state=urlsafe_base64_encode(force_bytes(user_email)) + ) + + return authorization_url + + +def build_credentials(google_credentials): + return Credentials(**{ + 'token': google_credentials.token, + 'refresh_token': google_credentials.refresh_token, + 'token_uri': "https://oauth2.googleapis.com/token", + 'client_id': settings.GOOGLE_AUTH['CLIENT_ID'], + 'client_secret': settings.GOOGLE_AUTH['CLIENT_SECRET'], + 'scopes': [s.scope for s in google_credentials.scopes.all()] + }) + + +def random_string_generator(length=16): + return ''.join(random.choice(string.ascii_letters + string.punctuation) for x in range(length)) diff --git a/integrations/migrations/0001_initial.py b/integrations/migrations/0001_initial.py new file mode 100644 index 00000000..da59fa91 --- /dev/null +++ b/integrations/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.13 on 2020-10-30 01:38 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='GoogleCredentials', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.TextField()), + ('refresh_token', models.TextField()), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_credentials', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='GoogleCredentialsScope', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scope', models.TextField()), + ('credentials', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scopes', to='integrations.GoogleCredentials')), + ], + ), + ] diff --git a/integrations/models.py b/integrations/models.py index 71a83623..81549e0b 100644 --- a/integrations/models.py +++ b/integrations/models.py @@ -1,3 +1,14 @@ from django.db import models -# Create your models here. +from users.models import User + + +class GoogleCredentials(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='google_credentials') + token = models.TextField() + refresh_token = models.TextField() + + +class GoogleCredentialsScope(models.Model): + scope = models.TextField() + credentials = models.ForeignKey(GoogleCredentials, on_delete=models.CASCADE, related_name='scopes') diff --git a/integrations/serializers.py b/integrations/serializers.py new file mode 100644 index 00000000..aa91c374 --- /dev/null +++ b/integrations/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + + +class GoogleOAuthSerializer(serializers.Serializer): + authorization_url = serializers.CharField() + + +class GCalDateTimeSerializer(serializers.Serializer): + date_time = serializers.DateTimeField() + + +class GCalEventSerializer(serializers.Serializer): + id = serializers.CharField(read_only=True) + html_link = serializers.CharField(read_only=True) + summary = serializers.CharField() + description = serializers.CharField() + start = GCalDateTimeSerializer() + end = GCalDateTimeSerializer() + hangout_link = serializers.CharField(read_only=True) diff --git a/integrations/urls.py b/integrations/urls.py index 4a172a6b..81b54ab5 100644 --- a/integrations/urls.py +++ b/integrations/urls.py @@ -4,4 +4,5 @@ urlpatterns = [ path('google/auth-request/', views.auth_request, name='google-auth-request'), path('google/auth-success/', views.auth_success, name='google-auth-success'), + path('google/calendar/', views.create_calendar_event, name='google-calendar'), ] diff --git a/integrations/views.py b/integrations/views.py index 51789209..9fae961f 100644 --- a/integrations/views.py +++ b/integrations/views.py @@ -1,37 +1,36 @@ from django.http import HttpResponse -from django.shortcuts import redirect -from django.conf import settings -import google_auth_oauthlib.flow - - -def auth_request(request): - client_secrets = { - "web": { - "client_id": settings.GOOGLE_AUTH['CLIENT_ID'], - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - "client_secret": settings.GOOGLE_AUTH['CLIENT_SECRET'], - "redirect_uris": [settings.GOOGLE_AUTH['REDIRECT_URI']] - } - } +from django.utils.http import urlsafe_base64_decode - flow = google_auth_oauthlib.flow.Flow.from_client_config( - client_secrets, - scopes=['https://www.googleapis.com/auth/calendar.events'], - ) +from integrations.models import GoogleCredentials, GoogleCredentialsScope +from integrations.google import get_google_flow +from users.models import User - flow.redirect_uri = settings.GOOGLE_AUTH['REDIRECT_URI'] - authorization_url, state = flow.authorization_url( - # Enable offline access so that you can refresh an access token without - # re-prompting the user for permission. Recommended for web server apps. - access_type='offline', - # Enable incremental authorization. Recommended as a best practice. - include_granted_scopes='true', - ) +def auth_success(request): + error = request.GET.get('error') + # TODO: Redirect to frontend page + if error: + return HttpResponse(f"

There was an error authorizing with Google.

{request.GET.get('error')}

") - return redirect(authorization_url) + user_email_bytes = urlsafe_base64_decode(request.GET.get('state')) + user_email = user_email_bytes.decode('utf-8') if user_email_bytes is not None else None + user = User.objects.get(email=user_email) -def auth_success(request): + flow = get_google_flow() + flow.fetch_token(authorization_response=request.get_raw_uri()) + + user_credentials, created = GoogleCredentials.objects.get_or_create( + user=user, + defaults={ + 'token': flow.credentials.token, + 'refresh_token': flow.credentials.refresh_token, + } + ) + + if created: + for scope in flow.credentials.scopes: + GoogleCredentialsScope.objects.create(scope=scope, credentials=user_credentials) + + # TODO: Redirect to frontend page return HttpResponse("

Yay, you authorized G Cal!

") diff --git a/pennychat/migrations/0004_pennychat_video_conference_link.py b/pennychat/migrations/0004_pennychat_video_conference_link.py new file mode 100644 index 00000000..7c03d482 --- /dev/null +++ b/pennychat/migrations/0004_pennychat_video_conference_link.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-11-11 01:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pennychat', '0003_pennychat_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='pennychat', + name='video_conference_link', + field=models.TextField(null=True), + ), + ] diff --git a/pennychat/models.py b/pennychat/models.py index d15b16df..d4958357 100644 --- a/pennychat/models.py +++ b/pennychat/models.py @@ -34,6 +34,7 @@ class PennyChat(models.Model): status = models.IntegerField(choices=STATUS_CHOICES, default=DRAFT) created_from_slack_team_id = models.CharField(max_length=20, null=True) visibility = models.IntegerField(choices=VISIBILITY_CHOICES, default=PUBLIC) + video_conference_link = models.TextField(null=True) # meta created = models.DateTimeField(auto_now_add=True) diff --git a/requirements.txt b/requirements.txt index 36a68bde..01dfa1a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ django-background-tasks==1.2.5 django-cors-headers>=3.2.1 django-filter==2.2.0 djangorestframework>=3.10.3 +djangorestframework-camel-case>=1.2.0 dj-database-url==0.5.0 dj-rest-auth==1.0.1 google-api-python-client==1.12.4 From 35f82bd1fe37ed92daedbbb6dfe06a397e783ee2 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Sun, 15 Nov 2020 11:54:58 -0600 Subject: [PATCH 03/10] Fix urls --- integrations/urls.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/integrations/urls.py b/integrations/urls.py index 81b54ab5..ac4f4219 100644 --- a/integrations/urls.py +++ b/integrations/urls.py @@ -2,7 +2,5 @@ from . import views urlpatterns = [ - path('google/auth-request/', views.auth_request, name='google-auth-request'), - path('google/auth-success/', views.auth_success, name='google-auth-success'), - path('google/calendar/', views.create_calendar_event, name='google-calendar'), + path('google/auth-success/', views.auth_success, name='google-auth-success') ] From e1b9adaade76091aff4c066e498d19891b34c3f9 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Sun, 15 Nov 2020 12:23:45 -0600 Subject: [PATCH 04/10] Fix tests --- bot/processors/pennychat.py | 23 ++++++++++++----------- bot/tasks/pennychat.py | 33 +++++++++++++++++++++++++++++++-- bot/utils.py | 30 ------------------------------ 3 files changed, 43 insertions(+), 43 deletions(-) diff --git a/bot/processors/pennychat.py b/bot/processors/pennychat.py index 0d55c4bb..978a9d40 100644 --- a/bot/processors/pennychat.py +++ b/bot/processors/pennychat.py @@ -4,12 +4,13 @@ from pytz import timezone, utc import requests from sentry_sdk import capture_exception +from slack.errors import SlackApiError from bot.tasks import ( post_organizer_edit_after_share_blocks, share_penny_chat_invitation, ) -from bot.utils import chat_postEphemeral_with_fallback, build_share_string +from bot.utils import chat_postEphemeral_with_fallback from integrations.google import build_credentials, GoogleCalendar, get_authorization_url from integrations.models import GoogleCredentials from pennychat.models import ( @@ -89,7 +90,7 @@ def penny_chat_details_modal(penny_chat_invitation): }, 'submit': { 'type': 'plain_text', - 'text': 'Submit :floppy_disk:', + 'text': 'Share Invite :the_horns:', }, 'blocks': [ { @@ -424,8 +425,13 @@ def visibility_select(self, event): penny_chat_invitation.save() def add_google_meet(self, penny_chat_invitation): - user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user - google_credentials = GoogleCredentials.objects.get(user=user) + try: + user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user + google_credentials = GoogleCredentials.objects.get(user=user) + except SlackApiError: + return None + except GoogleCredentials.DoesNotExist: + return None credentials = build_credentials(google_credentials) calendar = GoogleCalendar(credentials=credentials) @@ -461,15 +467,10 @@ def submit_details_and_share(self, event): penny_chat_invitation.save_organizer_from_slack_id(penny_chat_invitation.organizer_slack_id) - user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user - - if user.google_credentials is None: - update_modal = add_google_integration_modal(view['id']) - response = self.slack_client.views_push(view=update_modal, trigger_id=event['trigger_id']) - if not penny_chat_invitation.video_conference_link: meet = self.add_google_meet(penny_chat_invitation) - penny_chat_invitation.video_conference_link = meet['hangoutLink'] + if meet is not None: + penny_chat_invitation.video_conference_link = meet['hangoutLink'] # Ready to share penny_chat_invitation.status = PennyChatSlackInvitation.SHARED diff --git a/bot/tasks/pennychat.py b/bot/tasks/pennychat.py index 9a780bbe..38ee5269 100644 --- a/bot/tasks/pennychat.py +++ b/bot/tasks/pennychat.py @@ -8,12 +8,11 @@ from pytz import timezone, utc from sentry_sdk import capture_exception -from bot.utils import build_share_string, comma_split from common.utils import get_slack_client from pennychat.models import PennyChatSlackInvitation, Participant from users.models import ( SocialProfile, - get_or_create_social_profile_from_slack_id, + get_or_create_social_profile_from_slack_id, get_or_create_social_profile_from_slack_ids, ) VIEW_SUBMISSION = 'view_submission' @@ -104,6 +103,36 @@ def share_penny_chat_invitation(penny_chat_id): penny_chat_invitation.save() +def comma_split(comma_delimited_string): + """normal string split for ''.split(',') returns [''], so using this instead""" + return [x for x in comma_delimited_string.split(',') if x] + + +def build_share_string(slack_client, penny_chat_invitation): + shares = [] + users = get_or_create_social_profile_from_slack_ids( + comma_split(penny_chat_invitation.invitees), + slack_client=slack_client, + ) + for slack_user_id in comma_split(penny_chat_invitation.invitees): + shares.append(users[slack_user_id].real_name) + + if len(penny_chat_invitation.channels) > 0: + for channel in comma_split(penny_chat_invitation.channels): + shares.append(f'<#{channel}>') + + share_string = '' + if len(shares) == 1: + share_string = shares[0] + elif len(shares) == 2: + share_string = ' and '.join(shares) + elif len(shares) > 2: + shares[-1] = f'and {shares[-1]}' + share_string = ', '.join(shares) + + return share_string + + def send_penny_chat_reminders_and_mark_chat_as_reminded(): """This sends out reminders for any chat that is about to happen and also marks a chat as REMINDED. diff --git a/bot/utils.py b/bot/utils.py index 2f24b56e..d2c81958 100644 --- a/bot/utils.py +++ b/bot/utils.py @@ -30,36 +30,6 @@ def notify_admins(slack_client, message): pass -def comma_split(comma_delimited_string): - """normal string split for ''.split(',') returns [''], so using this instead""" - return [x for x in comma_delimited_string.split(',') if x] - - -def build_share_string(slack_client, penny_chat_invitation): - shares = [] - users = get_or_create_social_profile_from_slack_ids( - comma_split(penny_chat_invitation.invitees), - slack_client=slack_client, - ) - for slack_user_id in comma_split(penny_chat_invitation.invitees): - shares.append(users[slack_user_id].real_name) - - if len(penny_chat_invitation.channels) > 0: - for channel in comma_split(penny_chat_invitation.channels): - shares.append(f'<#{channel}>') - - share_string = '' - if len(shares) == 1: - share_string = shares[0] - elif len(shares) == 2: - share_string = ' and '.join(shares) - elif len(shares) > 2: - shares[-1] = f'and {shares[-1]}' - share_string = ', '.join(shares) - - return share_string - - def chat_postEphemeral_with_fallback(slack_client, channel, user, blocks=None, text=None): try: slack_client.chat_postEphemeral(channel=channel, user=user, blocks=blocks, text=text) From 0168e780f0aaefb783992c89119ea4641e6c1618 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Mon, 16 Nov 2020 06:42:11 -0600 Subject: [PATCH 05/10] Add frontend page and task for adding a gcal event --- bot/processors/pennychat.py | 70 ++---------- bot/tasks/pennychat.py | 104 ++++++++++++++++-- integrations/migrations/0001_initial.py | 4 +- integrations/views.py | 53 ++++----- penny_university_frontend/src/App.tsx | 4 +- .../src/components/buttons/index.tsx | 20 +++- .../src/constants/index.ts | 1 + .../src/pages/GoogleIntegration.tsx | 39 +++++++ 8 files changed, 191 insertions(+), 104 deletions(-) create mode 100644 penny_university_frontend/src/pages/GoogleIntegration.tsx diff --git a/bot/processors/pennychat.py b/bot/processors/pennychat.py index 978a9d40..65cd00d7 100644 --- a/bot/processors/pennychat.py +++ b/bot/processors/pennychat.py @@ -4,15 +4,15 @@ from pytz import timezone, utc import requests from sentry_sdk import capture_exception -from slack.errors import SlackApiError from bot.tasks import ( post_organizer_edit_after_share_blocks, share_penny_chat_invitation, + add_google_meet, + add_google_integration_blocks, ) from bot.utils import chat_postEphemeral_with_fallback -from integrations.google import build_credentials, GoogleCalendar, get_authorization_url -from integrations.models import GoogleCredentials +from integrations.google import get_authorization_url from pennychat.models import ( PennyChat, PennyChatSlackInvitation, @@ -29,7 +29,6 @@ VIEW_SUBMISSION = 'view_submission' VIEW_CLOSED = 'view_closed' -ADD_GOOGLE_INTEGRATION = 'add_google_integration' PENNY_CHAT_DATE = 'penny_chat_date' PENNY_CHAT_TIME = 'penny_chat_time' PENNY_CHAT_USER_SELECT = 'penny_chat_user_select' @@ -236,40 +235,6 @@ def penny_chat_details_modal(penny_chat_invitation): return template -def add_google_integration_modal(authorization_url): - template = { - 'type': 'modal', - 'notify_on_close': True, - 'callback_id': "google_auth_callback", - 'title': { - 'type': 'plain_text', - 'text': 'Penny Chat Details' - }, - 'blocks': [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Click the button to activate the Google Calendar integration." - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Add Google Integration", - "emoji": True - }, - "value": "add_integration", - "url": authorization_url, - "action_id": ADD_GOOGLE_INTEGRATION - } - } - ] - } - - return template - - class PennyChatBotModule(BotModule): """Responsible for all interactions related to the `/penny chat` command. @@ -344,8 +309,8 @@ def create_penny_chat(cls, slack, event): @classmethod def integrate_google_calendar(cls, slack, event): user = get_or_create_social_profile_from_slack_id(event['user_id']) - modal = add_google_integration_modal(authorization_url=get_authorization_url(user.email)) - slack.views_open(view=modal, trigger_id=event['trigger_id']) + blocks = add_google_integration_blocks(authorization_url=get_authorization_url(user.email)) + chat_postEphemeral_with_fallback(slack, channel=event['channel_id'], user=event['user_id'], blocks=blocks) @is_block_interaction_event @has_action_id(PENNY_CHAT_SCHEDULE_MATCH) @@ -424,23 +389,6 @@ def visibility_select(self, event): penny_chat_invitation.visibility = int(selected_visibility) penny_chat_invitation.save() - def add_google_meet(self, penny_chat_invitation): - try: - user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user - google_credentials = GoogleCredentials.objects.get(user=user) - except SlackApiError: - return None - except GoogleCredentials.DoesNotExist: - return None - credentials = build_credentials(google_credentials) - calendar = GoogleCalendar(credentials=credentials) - - return calendar.create_event( - summary=penny_chat_invitation.title, - description=penny_chat_invitation.description, - start=penny_chat_invitation.date - ) - @has_event_type([VIEW_SUBMISSION, VIEW_CLOSED]) @has_callback_id(PENNY_CHAT_DETAILS) def submit_details_and_share(self, event): @@ -467,17 +415,15 @@ def submit_details_and_share(self, event): penny_chat_invitation.save_organizer_from_slack_id(penny_chat_invitation.organizer_slack_id) - if not penny_chat_invitation.video_conference_link: - meet = self.add_google_meet(penny_chat_invitation) - if meet is not None: - penny_chat_invitation.video_conference_link = meet['hangoutLink'] - # Ready to share penny_chat_invitation.status = PennyChatSlackInvitation.SHARED penny_chat_invitation.save() post_organizer_edit_after_share_blocks.now(view['id']) penny_chat_invitation.save_organizer_from_slack_id(penny_chat_invitation.organizer_slack_id) + + if not penny_chat_invitation.video_conference_link: + add_google_meet(penny_chat_invitation.id) share_penny_chat_invitation(penny_chat_invitation.id) @is_block_interaction_event diff --git a/bot/tasks/pennychat.py b/bot/tasks/pennychat.py index 38ee5269..01f318d8 100644 --- a/bot/tasks/pennychat.py +++ b/bot/tasks/pennychat.py @@ -9,6 +9,8 @@ from sentry_sdk import capture_exception from common.utils import get_slack_client +from integrations.google import build_credentials, get_authorization_url, GoogleCalendar +from integrations.models import GoogleCredentials from pennychat.models import PennyChatSlackInvitation, Participant from users.models import ( SocialProfile, @@ -18,6 +20,7 @@ VIEW_SUBMISSION = 'view_submission' VIEW_CLOSED = 'view_closed' +ADD_GOOGLE_INTEGRATION = 'add_google_integration' PENNY_CHAT_DATE = 'penny_chat_date' PENNY_CHAT_TIME = 'penny_chat_time' PENNY_CHAT_USER_SELECT = 'penny_chat_user_select' @@ -31,7 +34,6 @@ PENNY_CHAT_ID = 'penny_chat_id' - PREVIEW, INVITE, UPDATE, REMIND = 'review', 'invite', 'update', 'remind' PENNY_CHAT_DETAILS_BLOCKS_MODES = {PREVIEW, INVITE, UPDATE, REMIND} @@ -64,6 +66,34 @@ def post_organizer_edit_after_share_blocks(penny_chat_view_id): ) +@background +def add_google_meet(penny_chat_id): + penny_chat_invitation = PennyChatSlackInvitation.objects.get(id=penny_chat_id) + user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user + try: + google_credentials = GoogleCredentials.objects.get(user=user) + except GoogleCredentials.DoesNotExist: + slack_client = get_slack_client() + authorization_url = get_authorization_url(user) + slack_client.chat_postMessage( + channel=penny_chat_invitation.organizer_slack_id, + blocks=add_google_integration_blocks(authorization_url, from_penny_chat=True), + ) + return + + credentials = build_credentials(google_credentials) + calendar = GoogleCalendar(credentials=credentials) + + meet = calendar.create_event( + summary=penny_chat_invitation.title, + description=penny_chat_invitation.description, + start=penny_chat_invitation.date + ) + + penny_chat_invitation.video_conference_link = meet['hangoutLink'] + penny_chat_invitation.save() + + @background def share_penny_chat_invitation(penny_chat_id): """Shares penny chat invitations with people and channels in the invitee list.""" @@ -230,9 +260,9 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): description = f'{penny_chat_invitation.description} [Video Link]({penny_chat_invitation.video_conference_link})' google_cal_url = 'https://calendar.google.com/calendar/render?' \ 'action=TEMPLATE&text=' \ - f'{urllib.parse.quote(penny_chat_invitation.title)}&dates=' \ - f'{start_date}/{end_date}&details=' \ - f'{urllib.parse.quote(description)}' + f'{urllib.parse.quote(penny_chat_invitation.title)}&dates=' \ + f'{start_date}/{end_date}&details=' \ + f'{urllib.parse.quote(description)}' date_time_block['accessory'] = { 'type': 'button', @@ -277,7 +307,7 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): 'type': 'section', 'text': { 'type': 'mrkdwn', - 'text': f'*Video Call Link*' + 'text': '*Video Call Link*' } } ) @@ -287,7 +317,7 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): 'type': 'section', 'text': { 'type': 'mrkdwn', - 'text': f'A video link will be provided shortly before the chat starts' + 'text': 'A video link will be provided shortly before the chat starts' } } ) @@ -300,7 +330,7 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): 'type': 'button', 'text': { 'type': 'plain_text', - 'text': 'Join Video Call :call_me_hand:', + 'text': ':call_me_hand: Join Video Call', 'emoji': True, }, 'url': penny_chat_invitation.video_conference_link, @@ -449,8 +479,9 @@ def organizer_edit_after_share_blocks(slack_client, penny_chat_invitation): 'text': { 'type': 'mrkdwn', 'text': f'*:point_up: You just shared this invitation with:* {share_string}. ' - 'We will notify you as invitees respond.\n\n' - 'In the meantime if you need to update the event, click the button below.' + 'We will notify you as invitees respond.\n\n' + 'In the meantime if you need to update the event, click the button below.\n\n' + '*If you have enabled Google Calendar, a video link will be provided automatically.*' } }, { @@ -460,7 +491,7 @@ def organizer_edit_after_share_blocks(slack_client, penny_chat_invitation): 'type': 'button', 'text': { 'type': 'plain_text', - 'text': 'Edit Details :pencil2:', + 'text': ':pencil2: Edit Details', 'emoji': True, }, # TODO should this be a helper function? @@ -474,3 +505,56 @@ def organizer_edit_after_share_blocks(slack_client, penny_chat_invitation): ] return shared_message_preview_blocks + + +def missing_google_auth_blocks(): + blocks = [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': 'Awesome, it looks like you just shared a Penny Chat!' + } + }, + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': 'If you want to make the Penny Chat experience even better, consider adding our Google Calendar integration so that we can automatically add video conference links to your Penny Chat.' # noqa + } + }, + ] + + return blocks + + +def add_google_integration_blocks(authorization_url, from_penny_chat=False): + pre_add_button_blocks = missing_google_auth_blocks() if from_penny_chat else None + blocks = pre_add_button_blocks + [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': 'Click the button below to activate the Google Calendar integration.' + } + }, + { + 'type': 'actions', + 'elements': [ + { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Add Google Integration', + 'emoji': True + }, + 'value': 'add_integration', + 'style': 'primary', + 'url': authorization_url, + 'action_id': ADD_GOOGLE_INTEGRATION + }, + ] + }, + ] + + return blocks diff --git a/integrations/migrations/0001_initial.py b/integrations/migrations/0001_initial.py index da59fa91..ee578d2d 100644 --- a/integrations/migrations/0001_initial.py +++ b/integrations/migrations/0001_initial.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('token', models.TextField()), ('refresh_token', models.TextField()), - ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_credentials', to=settings.AUTH_USER_MODEL)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='google_credentials', to=settings.AUTH_USER_MODEL)), # noqa ], ), migrations.CreateModel( @@ -28,7 +28,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('scope', models.TextField()), - ('credentials', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scopes', to='integrations.GoogleCredentials')), + ('credentials', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scopes', to='integrations.GoogleCredentials')), # noqa ], ), ] diff --git a/integrations/views.py b/integrations/views.py index 9fae961f..cd851280 100644 --- a/integrations/views.py +++ b/integrations/views.py @@ -1,6 +1,8 @@ -from django.http import HttpResponse +from django.shortcuts import redirect from django.utils.http import urlsafe_base64_decode +from django.conf import settings +from common.utils import build_url from integrations.models import GoogleCredentials, GoogleCredentialsScope from integrations.google import get_google_flow from users.models import User @@ -8,29 +10,28 @@ def auth_success(request): error = request.GET.get('error') - # TODO: Redirect to frontend page if error: - return HttpResponse(f"

There was an error authorizing with Google.

{request.GET.get('error')}

") - - user_email_bytes = urlsafe_base64_decode(request.GET.get('state')) - user_email = user_email_bytes.decode('utf-8') if user_email_bytes is not None else None - - user = User.objects.get(email=user_email) - - flow = get_google_flow() - flow.fetch_token(authorization_response=request.get_raw_uri()) - - user_credentials, created = GoogleCredentials.objects.get_or_create( - user=user, - defaults={ - 'token': flow.credentials.token, - 'refresh_token': flow.credentials.refresh_token, - } - ) - - if created: - for scope in flow.credentials.scopes: - GoogleCredentialsScope.objects.create(scope=scope, credentials=user_credentials) - - # TODO: Redirect to frontend page - return HttpResponse("

Yay, you authorized G Cal!

") + url = build_url(settings.FRONT_END_HOST, 'google-integration', status='error', message=error) + else: + user_email_bytes = urlsafe_base64_decode(request.GET.get('state')) + user_email = user_email_bytes.decode('utf-8') if user_email_bytes is not None else None + + user = User.objects.get(email=user_email) + + flow = get_google_flow() + flow.fetch_token(authorization_response=request.get_raw_uri()) + + user_credentials, created = GoogleCredentials.objects.get_or_create( + user=user, + defaults={ + 'token': flow.credentials.token, + 'refresh_token': flow.credentials.refresh_token, + } + ) + + if created: + for scope in flow.credentials.scopes: + GoogleCredentialsScope.objects.create(scope=scope, credentials=user_credentials) + + url = build_url(settings.FRONT_END_HOST, 'google-integration', status='success') + return redirect(url) diff --git a/penny_university_frontend/src/App.tsx b/penny_university_frontend/src/App.tsx index 01fdd104..d45cc7c9 100644 --- a/penny_university_frontend/src/App.tsx +++ b/penny_university_frontend/src/App.tsx @@ -20,6 +20,7 @@ import { Routes } from './constants' import { User } from './models' import PasswordResetPage from './pages/PasswordReset'; import config from './config' +import GoogleIntegrationPage from './pages/GoogleIntegration'; type StateProps = { user: User, @@ -47,8 +48,9 @@ const App = (props: Props) => { - + + diff --git a/penny_university_frontend/src/components/buttons/index.tsx b/penny_university_frontend/src/components/buttons/index.tsx index 108f450a..37ed003e 100644 --- a/penny_university_frontend/src/components/buttons/index.tsx +++ b/penny_university_frontend/src/components/buttons/index.tsx @@ -1,7 +1,7 @@ import React from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { - faPlus, faTimes, faTrash, faPen, faSave, faHeart, IconDefinition, faCog, faEllipsisH, + faPlus, faTimes, faTrash, faPen, faSave, faHeart, IconDefinition, faCog, faEllipsisH, faHashtag, } from '@fortawesome/free-solid-svg-icons' import classNames from 'classnames/dedupe' import { Button } from 'reactstrap' @@ -12,6 +12,7 @@ interface Props { className: string, size: 'md', onClick: () => void | null, + href: string | null, title: string, detail: string, icon: IconDefinition, @@ -21,7 +22,7 @@ interface Props { } const IconButton = ({ - className, size, onClick, title, detail, icon, color, id, testID, + className, size, onClick, href, title, detail, icon, color, id, testID, }: Props) => { const text = title && detail ? `${title} ${detail}` : title || detail return ( @@ -29,6 +30,7 @@ const IconButton = ({ size={size} color={color} onClick={onClick} + href={href} className={classNames(className, 'edit-button')} id={id} data-testid={testID} @@ -46,6 +48,7 @@ IconButton.defaultProps = { className: '', title: '', onClick: null, + href: null, id: '', testID: null, } @@ -131,8 +134,19 @@ SettingsButton.defaultProps = { className: '', } +const SlackButton = ({ + className, href, +}: { className: string, href: string | null }) => ( + +) + +SlackButton.defaultProps = { + className: '', + href: 'slack://open', +} + export { - HeartButton, CreateButton, DeleteButton, EditButton, SaveButton, CancelButton, MoreOptions, SettingsButton, + HeartButton, CreateButton, DeleteButton, EditButton, SaveButton, CancelButton, MoreOptions, SettingsButton, SlackButton, } export default IconButton diff --git a/penny_university_frontend/src/constants/index.ts b/penny_university_frontend/src/constants/index.ts index 77e660bc..350bf341 100644 --- a/penny_university_frontend/src/constants/index.ts +++ b/penny_university_frontend/src/constants/index.ts @@ -22,6 +22,7 @@ export const Routes = { Chats: '/chats', ChatDetail: '/chats/:id', Home: '/', + GoogleIntegration: '/google-integration', ResetPassword: '/reset-password', VerifyEmail: '/verify', } diff --git a/penny_university_frontend/src/pages/GoogleIntegration.tsx b/penny_university_frontend/src/pages/GoogleIntegration.tsx new file mode 100644 index 00000000..99304c20 --- /dev/null +++ b/penny_university_frontend/src/pages/GoogleIntegration.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import { RouteComponentProps } from 'react-router-dom' +import queryString from 'query-string' +import { Card, CardBody } from 'reactstrap'; +import {SlackButton} from "../components/buttons"; + +type PasswordResetPageProps = RouteComponentProps<{}> + +const PasswordResetPage = ({ + location, +}: PasswordResetPageProps) => { + const parsed = queryString.parse(location.search); + + if (parsed?.status === 'error' && typeof parsed?.message === 'string') { + return ( + + +

Error integrating Google Calendar

+

{parsed.message}

+
+
+ ) + } if (parsed?.status === 'success') { + return ( + + +

The Google Calendar integration was successful!

+
+ +
+
+
+ ) + } + return null +} + +// @ts-ignore +export default PasswordResetPage From ccbdd7e78b3767ce260958bbd82971de763bf247 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Mon, 16 Nov 2020 08:45:59 -0600 Subject: [PATCH 06/10] PR updates --- bot/tasks/pennychat.py | 8 ++++++-- docs/integrations.md | 2 +- integrations/urls.py | 2 +- integrations/views.py | 5 ++++- users/models.py | 3 +++ 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/bot/tasks/pennychat.py b/bot/tasks/pennychat.py index 01f318d8..07a910da 100644 --- a/bot/tasks/pennychat.py +++ b/bot/tasks/pennychat.py @@ -7,6 +7,7 @@ from django.conf import settings from pytz import timezone, utc from sentry_sdk import capture_exception +from slack.errors import SlackApiError from common.utils import get_slack_client from integrations.google import build_credentials, get_authorization_url, GoogleCalendar @@ -68,18 +69,21 @@ def post_organizer_edit_after_share_blocks(penny_chat_view_id): @background def add_google_meet(penny_chat_id): + slack_client = get_slack_client() penny_chat_invitation = PennyChatSlackInvitation.objects.get(id=penny_chat_id) - user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user + user = None try: + user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user google_credentials = GoogleCredentials.objects.get(user=user) except GoogleCredentials.DoesNotExist: - slack_client = get_slack_client() authorization_url = get_authorization_url(user) slack_client.chat_postMessage( channel=penny_chat_invitation.organizer_slack_id, blocks=add_google_integration_blocks(authorization_url, from_penny_chat=True), ) return + except SlackApiError: + return credentials = build_credentials(google_credentials) calendar = GoogleCalendar(credentials=credentials) diff --git a/docs/integrations.md b/docs/integrations.md index e03d4d4d..c4b9fca6 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,6 +1,6 @@ Go to https://developers.google.com/calendar/quickstart/python?authuser=1 and enable the calendar API in order to get credentials for your dev account -Use: http://localhost:8000/integrations/google/auth-success for your redirect uri +Use: http://localhost:8000/integrations/google/auth-callback for your redirect uri Copy credentials to these respective settings: * export GOOGLE_CLIENT_ID diff --git a/integrations/urls.py b/integrations/urls.py index ac4f4219..c4b40c5d 100644 --- a/integrations/urls.py +++ b/integrations/urls.py @@ -2,5 +2,5 @@ from . import views urlpatterns = [ - path('google/auth-success/', views.auth_success, name='google-auth-success') + path('google/auth-callback/', views.auth_callback, name='google-auth-callback') ] diff --git a/integrations/views.py b/integrations/views.py index cd851280..b8455526 100644 --- a/integrations/views.py +++ b/integrations/views.py @@ -2,16 +2,19 @@ from django.utils.http import urlsafe_base64_decode from django.conf import settings +from sentry_sdk import capture_exception + from common.utils import build_url from integrations.models import GoogleCredentials, GoogleCredentialsScope from integrations.google import get_google_flow from users.models import User -def auth_success(request): +def auth_callback(request): error = request.GET.get('error') if error: url = build_url(settings.FRONT_END_HOST, 'google-integration', status='error', message=error) + capture_exception(Exception(error)) else: user_email_bytes = urlsafe_base64_decode(request.GET.get('state')) user_email = user_email_bytes.decode('utf-8') if user_email_bytes is not None else None diff --git a/users/models.py b/users/models.py index fcdd08a2..d6dc4572 100644 --- a/users/models.py +++ b/users/models.py @@ -1,3 +1,5 @@ +import functools + from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError @@ -135,6 +137,7 @@ def update_social_profile_from_slack_user(slack_user): return profile, created +@functools.lru_cache() # memoizes the function def get_or_create_social_profile_from_slack_id(slack_user_id, slack_client=None, ignore_user_not_found=True): return get_or_create_social_profile_from_slack_ids( [slack_user_id], From 9bea3e188f54f98433a8dadf76b72bceef79cb4d Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Thu, 19 Nov 2020 12:41:37 -0600 Subject: [PATCH 07/10] Add signal to create integration after save --- bot/processors/pennychat.py | 4 +- bot/tasks/pennychat.py | 69 ++++++++++++------- integrations/__init__.py | 1 + integrations/apps.py | 3 + integrations/google.py | 26 +++---- integrations/signals.py | 18 +++++ .../0005_pennychat_google_event_id.py | 18 +++++ pennychat/models.py | 1 + 8 files changed, 101 insertions(+), 39 deletions(-) create mode 100644 integrations/signals.py create mode 100644 pennychat/migrations/0005_pennychat_google_event_id.py diff --git a/bot/processors/pennychat.py b/bot/processors/pennychat.py index 65cd00d7..d156914b 100644 --- a/bot/processors/pennychat.py +++ b/bot/processors/pennychat.py @@ -9,7 +9,7 @@ post_organizer_edit_after_share_blocks, share_penny_chat_invitation, add_google_meet, - add_google_integration_blocks, + add_google_integration_blocks, update_google_meet, ) from bot.utils import chat_postEphemeral_with_fallback from integrations.google import get_authorization_url @@ -424,6 +424,8 @@ def submit_details_and_share(self, event): if not penny_chat_invitation.video_conference_link: add_google_meet(penny_chat_invitation.id) + else: + update_google_meet(penny_chat_invitation.id) share_penny_chat_invitation(penny_chat_invitation.id) @is_block_interaction_event diff --git a/bot/tasks/pennychat.py b/bot/tasks/pennychat.py index 07a910da..0841ea6e 100644 --- a/bot/tasks/pennychat.py +++ b/bot/tasks/pennychat.py @@ -7,7 +7,6 @@ from django.conf import settings from pytz import timezone, utc from sentry_sdk import capture_exception -from slack.errors import SlackApiError from common.utils import get_slack_client from integrations.google import build_credentials, get_authorization_url, GoogleCalendar @@ -67,26 +66,31 @@ def post_organizer_edit_after_share_blocks(penny_chat_view_id): ) -@background -def add_google_meet(penny_chat_id): - slack_client = get_slack_client() - penny_chat_invitation = PennyChatSlackInvitation.objects.get(id=penny_chat_id) - user = None +def get_user_google_calendar_from_slack_id(slack_id): + user = get_or_create_social_profile_from_slack_id(slack_id).user try: - user = get_or_create_social_profile_from_slack_id(penny_chat_invitation.organizer_slack_id).user google_credentials = GoogleCredentials.objects.get(user=user) except GoogleCredentials.DoesNotExist: authorization_url = get_authorization_url(user) + slack_client = get_slack_client() slack_client.chat_postMessage( - channel=penny_chat_invitation.organizer_slack_id, + channel=slack_id, blocks=add_google_integration_blocks(authorization_url, from_penny_chat=True), ) return - except SlackApiError: - return credentials = build_credentials(google_credentials) - calendar = GoogleCalendar(credentials=credentials) + return GoogleCalendar(credentials=credentials) + + +@background +def add_google_meet(penny_chat_id): + penny_chat_invitation = PennyChatSlackInvitation.objects.get(id=penny_chat_id) + + calendar = get_user_google_calendar_from_slack_id(penny_chat_invitation.organizer_slack_id) + + if calendar is None: + return meet = calendar.create_event( summary=penny_chat_invitation.title, @@ -94,10 +98,25 @@ def add_google_meet(penny_chat_id): start=penny_chat_invitation.date ) - penny_chat_invitation.video_conference_link = meet['hangoutLink'] + penny_chat_invitation.video_conference_link = meet.get('hangoutLink') + penny_chat_invitation.google_event_id = meet.get('id') penny_chat_invitation.save() +@background +def update_google_meet(penny_chat_id): + penny_chat_invitation = PennyChatSlackInvitation.objects.get(id=penny_chat_id) + + calendar = get_user_google_calendar_from_slack_id(penny_chat_invitation.organizer_slack_id) + + calendar.update_event( + event_id=penny_chat_invitation.google_event_id, + summary=penny_chat_invitation.title, + description=penny_chat_invitation.description, + start=penny_chat_invitation.date + ) + + @background def share_penny_chat_invitation(penny_chat_id): """Shares penny chat invitations with people and channels in the invitee list.""" @@ -261,7 +280,7 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): start_date = penny_chat_invitation.date.astimezone(utc).strftime('%Y%m%dT%H%M%SZ') end_date = (penny_chat_invitation.date.astimezone(utc) + timedelta(hours=1)).strftime('%Y%m%dT%H%M%SZ') - description = f'{penny_chat_invitation.description} [Video Link]({penny_chat_invitation.video_conference_link})' + description = f'{penny_chat_invitation.description}\nVideo Link: {penny_chat_invitation.video_conference_link}' google_cal_url = 'https://calendar.google.com/calendar/render?' \ 'action=TEMPLATE&text=' \ f'{urllib.parse.quote(penny_chat_invitation.title)}&dates=' \ @@ -306,27 +325,25 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): ] if penny_chat_invitation.video_conference_link: - body.append( - { - 'type': 'section', - 'text': { - 'type': 'mrkdwn', - 'text': '*Video Call Link*' - } - } - ) if mode in {PREVIEW, INVITE, UPDATE}: body.append( { 'type': 'section', 'text': { 'type': 'mrkdwn', - 'text': 'A video link will be provided shortly before the chat starts' + 'text': '_(A video link will be provided shortly before the chat starts)_' } } ) elif mode in {REMIND}: - body.append( + body += [ + { + 'type': 'section', + 'text': { + 'type': 'mrkdwn', + 'text': '*Video Call Link*' + } + }, { 'type': 'actions', 'elements': [ @@ -342,7 +359,7 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): } ] } - ) + ] if include_rsvp: body.append( @@ -533,7 +550,7 @@ def missing_google_auth_blocks(): def add_google_integration_blocks(authorization_url, from_penny_chat=False): - pre_add_button_blocks = missing_google_auth_blocks() if from_penny_chat else None + pre_add_button_blocks = missing_google_auth_blocks() if from_penny_chat else [] blocks = pre_add_button_blocks + [ { 'type': 'section', diff --git a/integrations/__init__.py b/integrations/__init__.py index e69de29b..954aa372 100644 --- a/integrations/__init__.py +++ b/integrations/__init__.py @@ -0,0 +1 @@ +default_app_config = 'integrations.apps.IntegrationsConfig' diff --git a/integrations/apps.py b/integrations/apps.py index bb57e755..e0a4f633 100644 --- a/integrations/apps.py +++ b/integrations/apps.py @@ -3,3 +3,6 @@ class IntegrationsConfig(AppConfig): name = 'integrations' + + def ready(self): + import integrations.signals # noqa diff --git a/integrations/google.py b/integrations/google.py index 2ccc044a..176ce578 100644 --- a/integrations/google.py +++ b/integrations/google.py @@ -6,7 +6,6 @@ from django.utils.http import urlsafe_base64_encode from google_auth_oauthlib.flow import Flow from googleapiclient.discovery import build -from googleapiclient.errors import HttpError from google.oauth2.credentials import Credentials from django.conf import settings @@ -16,10 +15,7 @@ class GoogleCalendar: def __init__(self, credentials, calendar_id='primary'): self.service = build('calendar', 'v3', credentials=credentials) self.calendar_id = calendar_id - - @property - def events(self): - return self.service.events() + self.events = self.service.events() def add_conference_call_to_event(self, event_id): event_patch = { @@ -39,7 +35,8 @@ def add_conference_call_to_event(self, event_id): # Fetch updated event and return it return self.events.get(calendarId=self.calendar_id, eventId=event_id).execute() - def create_event(self, summary, description, start, end=None, with_meet=True): + @staticmethod + def build_event_data(summary, description, start, end): if not end: end = start + timedelta(hours=1) data = { @@ -52,16 +49,21 @@ def create_event(self, summary, description, start, end=None, with_meet=True): 'dateTime': end.isoformat(), } } - try: - event_data = self.events.insert(calendarId='primary', body=data).execute() + return data - if with_meet: - event_data = self.add_conference_call_to_event(event_id=event_data['id']) - except HttpError as e: - print(e) + def create_event(self, summary, description, start, end=None, with_meet=True): + data = GoogleCalendar.build_event_data(summary, description, start, end) + event_data = self.events.insert(calendarId='primary', body=data).execute() + + if with_meet: + event_data = self.add_conference_call_to_event(event_id=event_data['id']) return event_data + def update_event(self, event_id, summary, description, start, end=None): + data = GoogleCalendar.build_event_data(summary, description, start, end) + self.events.patch(calendarId='primary', eventId=event_id, body=data).execute() + def get_google_flow(): client_secrets = { diff --git a/integrations/signals.py b/integrations/signals.py new file mode 100644 index 00000000..d16e9911 --- /dev/null +++ b/integrations/signals.py @@ -0,0 +1,18 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone + +from bot.tasks import add_google_meet +from integrations.models import GoogleCredentials +from pennychat.models import PennyChatSlackInvitation + + +@receiver(post_save, sender=GoogleCredentials) +def add_google_meet_to_upcoming_chats(sender, **kwargs): + credentials = kwargs.get('instance') + if credentials: + user = credentials.user + slack_ids = [profile.slack_id for profile in user.social_profiles.all()] + invites = PennyChatSlackInvitation.objects.filter(organizer_slack_id__in=slack_ids, date__gt=timezone.now()) + for invite in invites: + add_google_meet(invite.id) diff --git a/pennychat/migrations/0005_pennychat_google_event_id.py b/pennychat/migrations/0005_pennychat_google_event_id.py new file mode 100644 index 00000000..f624e02c --- /dev/null +++ b/pennychat/migrations/0005_pennychat_google_event_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.13 on 2020-11-18 01:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pennychat', '0004_pennychat_video_conference_link'), + ] + + operations = [ + migrations.AddField( + model_name='pennychat', + name='google_event_id', + field=models.TextField(null=True), + ), + ] diff --git a/pennychat/models.py b/pennychat/models.py index d4958357..54fbf243 100644 --- a/pennychat/models.py +++ b/pennychat/models.py @@ -35,6 +35,7 @@ class PennyChat(models.Model): created_from_slack_team_id = models.CharField(max_length=20, null=True) visibility = models.IntegerField(choices=VISIBILITY_CHOICES, default=PUBLIC) video_conference_link = models.TextField(null=True) + google_event_id = models.TextField(null=True) # meta created = models.DateTimeField(auto_now_add=True) From 581c6925b3645bd9501499acb9985bf4e7ce2134 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Tue, 24 Nov 2020 19:41:08 -0600 Subject: [PATCH 08/10] Fix test --- bot/tests/processors/test_pennychat.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/tests/processors/test_pennychat.py b/bot/tests/processors/test_pennychat.py index f49707ac..5167d9ee 100644 --- a/bot/tests/processors/test_pennychat.py +++ b/bot/tests/processors/test_pennychat.py @@ -187,16 +187,17 @@ def id_mock(user_id, slack_client=None, ignore_user_not_found=True): post_organizer_edit_after_share_blocks = mocker.patch( 'bot.processors.pennychat.post_organizer_edit_after_share_blocks' ) + add_google_meet = mocker.patch('bot.processors.pennychat.add_google_meet') # The Actual Test (premature close) - with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks: # noqa + with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks, add_google_meet: # noqa PennyChatBotModule(mocker.Mock()).submit_details_and_share(event) assert share_penny_chat_invitation.call_count == 0 # The Actual Test (actual submission) event['type'] = penny_chat_constants.VIEW_SUBMISSION - with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks: # noqa + with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks, add_google_meet: # noqa PennyChatBotModule(mocker.Mock()).submit_details_and_share(event) assert share_penny_chat_invitation.call_args == call(penny_chat_invitation.id) From 858324fb47a7e3096218610510a5de94eaeb2255 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Tue, 24 Nov 2020 19:57:14 -0600 Subject: [PATCH 09/10] Fix lint --- penny_university_frontend/src/pages/GoogleIntegration.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/penny_university_frontend/src/pages/GoogleIntegration.tsx b/penny_university_frontend/src/pages/GoogleIntegration.tsx index 99304c20..550eae2b 100644 --- a/penny_university_frontend/src/pages/GoogleIntegration.tsx +++ b/penny_university_frontend/src/pages/GoogleIntegration.tsx @@ -2,7 +2,7 @@ import React from 'react' import { RouteComponentProps } from 'react-router-dom' import queryString from 'query-string' import { Card, CardBody } from 'reactstrap'; -import {SlackButton} from "../components/buttons"; +import { SlackButton } from '../components/buttons'; type PasswordResetPageProps = RouteComponentProps<{}> From f2425632a9c2f6527495234d35cbfbab69649812 Mon Sep 17 00:00:00 2001 From: Nick Chouard Date: Tue, 24 Nov 2020 21:42:52 -0600 Subject: [PATCH 10/10] Add tests --- bot/processors/pennychat.py | 3 +- bot/tasks/pennychat.py | 47 +++++++++++---------- bot/tests/processors/test_pennychat.py | 8 ++-- bot/tests/tasks/test_pennychat.py | 57 ++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/bot/processors/pennychat.py b/bot/processors/pennychat.py index d156914b..81cbb8c0 100644 --- a/bot/processors/pennychat.py +++ b/bot/processors/pennychat.py @@ -9,7 +9,8 @@ post_organizer_edit_after_share_blocks, share_penny_chat_invitation, add_google_meet, - add_google_integration_blocks, update_google_meet, + add_google_integration_blocks, + update_google_meet, ) from bot.utils import chat_postEphemeral_with_fallback from integrations.google import get_authorization_url diff --git a/bot/tasks/pennychat.py b/bot/tasks/pennychat.py index 0841ea6e..8113d523 100644 --- a/bot/tasks/pennychat.py +++ b/bot/tasks/pennychat.py @@ -109,6 +109,9 @@ def update_google_meet(penny_chat_id): calendar = get_user_google_calendar_from_slack_id(penny_chat_invitation.organizer_slack_id) + if calendar is None: + return + calendar.update_event( event_id=penny_chat_invitation.google_event_id, summary=penny_chat_invitation.title, @@ -277,25 +280,7 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): } if include_calendar_link: - start_date = penny_chat_invitation.date.astimezone(utc).strftime('%Y%m%dT%H%M%SZ') - end_date = (penny_chat_invitation.date.astimezone(utc) + timedelta(hours=1)).strftime('%Y%m%dT%H%M%SZ') - - description = f'{penny_chat_invitation.description}\nVideo Link: {penny_chat_invitation.video_conference_link}' - google_cal_url = 'https://calendar.google.com/calendar/render?' \ - 'action=TEMPLATE&text=' \ - f'{urllib.parse.quote(penny_chat_invitation.title)}&dates=' \ - f'{start_date}/{end_date}&details=' \ - f'{urllib.parse.quote(description)}' - - date_time_block['accessory'] = { - 'type': 'button', - 'text': { - 'type': 'plain_text', - 'text': 'Add to Google Calendar :calendar:', - 'emoji': True - }, - 'url': google_cal_url - } + date_time_block['accessory'] = _google_calendar_link_block(penny_chat_invitation) body = [ { @@ -453,6 +438,26 @@ def _penny_chat_details_blocks(penny_chat_invitation, mode=None): return body +def _google_calendar_link_block(penny_chat_invitation): + start_date = penny_chat_invitation.date.astimezone(utc).strftime('%Y%m%dT%H%M%SZ') + end_date = (penny_chat_invitation.date.astimezone(utc) + timedelta(hours=1)).strftime('%Y%m%dT%H%M%SZ') + description = f'{penny_chat_invitation.description}\nVideo Link: {penny_chat_invitation.video_conference_link}' + google_cal_url = 'https://calendar.google.com/calendar/render?' \ + 'action=TEMPLATE&text=' \ + f'{urllib.parse.quote(penny_chat_invitation.title)}&dates=' \ + f'{start_date}/{end_date}&details=' \ + f'{urllib.parse.quote(description)}' + return { + 'type': 'button', + 'text': { + 'type': 'plain_text', + 'text': 'Add to Google Calendar :calendar:', + 'emoji': True + }, + 'url': google_cal_url + } + + def _followup_reminder_blocks(penny_chat_invitation): organizer = get_or_create_social_profile_from_slack_id( penny_chat_invitation.organizer_slack_id, @@ -528,7 +533,7 @@ def organizer_edit_after_share_blocks(slack_client, penny_chat_invitation): return shared_message_preview_blocks -def missing_google_auth_blocks(): +def _missing_google_auth_blocks(): blocks = [ { 'type': 'section', @@ -550,7 +555,7 @@ def missing_google_auth_blocks(): def add_google_integration_blocks(authorization_url, from_penny_chat=False): - pre_add_button_blocks = missing_google_auth_blocks() if from_penny_chat else [] + pre_add_button_blocks = _missing_google_auth_blocks() if from_penny_chat else [] blocks = pre_add_button_blocks + [ { 'type': 'section', diff --git a/bot/tests/processors/test_pennychat.py b/bot/tests/processors/test_pennychat.py index 5167d9ee..3b4ba993 100644 --- a/bot/tests/processors/test_pennychat.py +++ b/bot/tests/processors/test_pennychat.py @@ -9,7 +9,7 @@ from bot.processors.pennychat import PennyChatBotModule import bot.processors.pennychat as penny_chat_constants -from bot.tasks.pennychat import _penny_chat_details_blocks +from bot.tasks.pennychat import _penny_chat_details_blocks, add_google_meet from matchmaking.models import Match, TopicChannel from pennychat.models import ( PennyChatSlackInvitation, @@ -187,17 +187,17 @@ def id_mock(user_id, slack_client=None, ignore_user_not_found=True): post_organizer_edit_after_share_blocks = mocker.patch( 'bot.processors.pennychat.post_organizer_edit_after_share_blocks' ) - add_google_meet = mocker.patch('bot.processors.pennychat.add_google_meet') + add_google_meet_mock = mocker.patch('bot.processors.pennychat.add_google_meet') # The Actual Test (premature close) - with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks, add_google_meet: # noqa + with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks, add_google_meet_mock: # noqa PennyChatBotModule(mocker.Mock()).submit_details_and_share(event) assert share_penny_chat_invitation.call_count == 0 # The Actual Test (actual submission) event['type'] = penny_chat_constants.VIEW_SUBMISSION - with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks, add_google_meet: # noqa + with mocker.patch('pennychat.models.get_or_create_social_profile_from_slack_id', side_effect=id_mock), post_organizer_edit_after_share_blocks, add_google_meet_mock: # noqa PennyChatBotModule(mocker.Mock()).submit_details_and_share(event) assert share_penny_chat_invitation.call_args == call(penny_chat_invitation.id) diff --git a/bot/tests/tasks/test_pennychat.py b/bot/tests/tasks/test_pennychat.py index ee9abb74..383f4bbc 100644 --- a/bot/tests/tasks/test_pennychat.py +++ b/bot/tests/tasks/test_pennychat.py @@ -13,10 +13,12 @@ post_organizer_edit_after_share_blocks, _penny_chat_details_blocks, _followup_reminder_blocks, + add_google_integration_blocks, organizer_edit_after_share_blocks, send_penny_chat_reminders_and_mark_chat_as_reminded, send_followup_reminder_and_mark_chat_as_completed, ) +from common.tests.fakes import PennyChatSlackInvitationFactory, SocialProfileFactory from pennychat.models import PennyChatSlackInvitation, Participant from users.models import User, SocialProfile @@ -391,6 +393,7 @@ def test_penny_chat_details_blocks(mocker): date=datetime(1979, 10, 12, 1, 1, 1, tzinfo=utc), organizer_slack_id=organizer_slack_id, created_from_slack_team_id=SLACK_TEAM_ID, + video_conference_link='http://meet.google.com/fake' ) organizer = SocialProfile(slack_id=organizer_slack_id, real_name='John Berryman', slack_team_id=SLACK_TEAM_ID) @@ -406,6 +409,7 @@ def test_penny_chat_details_blocks(mocker): assert 'Count me in' not in preview_blocks, 'should not be there when include_rsvp is False' assert 'I can\'t make it' not in preview_blocks, 'should not be there when include_rsvp is False' assert 'calendar.google.com' in preview_blocks, 'should have calendar link when include_calendar_link is True' + assert '_(A video link will be provided shortly before the chat starts)_' in preview_blocks assert '*John Berryman* invited you to a new Penny Chat' in invite_blocks, 'wrong header_text' assert '*Title*\\nChat 1' in invite_blocks @@ -413,6 +417,7 @@ def test_penny_chat_details_blocks(mocker): assert 'Count me in' in invite_blocks, 'should be there when include_rsvp is True' assert 'I can\'t make it' in invite_blocks, 'should be there when include_rsvp is True' assert 'calendar.google.com' in invite_blocks, 'should have calendar link when include_calendar_link is True' + assert '_(A video link will be provided shortly before the chat starts)_' in invite_blocks assert '*John Berryman* has updated their Penny Chat' in update_blocks, 'wrong header_text' assert '*Title*\\nChat 1' in update_blocks @@ -420,11 +425,13 @@ def test_penny_chat_details_blocks(mocker): assert 'Count me in' in update_blocks, 'should be there when include_rsvp is True' assert 'I can\'t make it' in update_blocks, 'should be there when include_rsvp is True' assert 'calendar.google.com' in update_blocks, 'should have calendar link when include_calendar_link is True' + assert '_(A video link will be provided shortly before the chat starts)_' in invite_blocks assert '*John Berryman\'s* Penny Chat is coming up soon! We hope you can still make it' in remind_blocks, \ 'wrong header_text' assert '*Title*\\nChat 1' in remind_blocks assert '*Description*\\nsome_description' in remind_blocks + assert 'http://meet.google.com/fake' in remind_blocks, 'should include button on reminder' assert 'Count me in' not in remind_blocks, 'should not be there when include_rsvp is False' assert 'I can\'t make it' not in remind_blocks, 'should not be there when include_rsvp is False' assert 'calendar.google.com'not in remind_blocks, 'should not have calendar link when include_calendar_link is False' @@ -493,3 +500,53 @@ def test_followup_reminder_blocks(mocker): assert 'John Berryman\\\'s Penny Chat *"Chat 1"* has completed.' in reminder_blocks assert "'url': 'https://www.pennyuniversity.org/chats/123'" in reminder_blocks + + +@pytest.mark.django_db +def test_add_google_meet(mocker): + calendar = mocker.Mock() + + response = { + 'id': 'fake_id', + 'hangoutLink': 'http://meet.google.com/fake', + } + + calendar.configure_mock(**{'create_event.return_value': response}) + + penny_chat = PennyChatSlackInvitationFactory() + + with mocker.patch('bot.tasks.pennychat.get_user_google_calendar_from_slack_id', return_value=calendar): + pennychat_constants.add_google_meet(penny_chat.id) + + penny_chat.refresh_from_db() + assert penny_chat.video_conference_link == 'http://meet.google.com/fake' + assert penny_chat.google_event_id == 'fake_id' + + +def test_add_google_integration_blocks(): + authorization_url = 'http://googleauth.test' + blocks_from_chat = str(add_google_integration_blocks(authorization_url, from_penny_chat=True)) + + assert 'Awesome, it looks like you just shared a Penny Chat!' in blocks_from_chat + assert authorization_url in blocks_from_chat + + blocks_not_from_chat = str(add_google_integration_blocks(authorization_url, from_penny_chat=False)) + assert 'Awesome, it looks like you just shared a Penny Chat!' not in blocks_not_from_chat + assert authorization_url in blocks_not_from_chat + + +@pytest.mark.django_db +def test_send_google_integration_message_if_does_not_exist(mocker): + profile = SocialProfileFactory() + slack_client = mocker.Mock() + get_slack_client = mocker.patch('bot.tasks.pennychat.get_slack_client', return_value=slack_client) + authorization_url = 'http://googleauth.test' + get_authorization_url = mocker.patch('bot.tasks.pennychat.get_authorization_url', return_value=authorization_url) + + with _make_get_or_create_social_profile_from_slack_id_mocks(mocker, 'bot.tasks.pennychat', [profile]), \ + get_slack_client, get_authorization_url: + pennychat_constants.get_user_google_calendar_from_slack_id(profile.slack_id) + + add_integration_blocks = add_google_integration_blocks(authorization_url, from_penny_chat=True) + + assert slack_client.chat_postMessage.call_args == call(blocks=add_integration_blocks, channel=profile.slack_id)