-
Notifications
You must be signed in to change notification settings - Fork 0
Improve publish flow when user's refresh token has expired or they don't have permission to the doc. #93
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Improve publish flow when user's refresh token has expired or they don't have permission to the doc. #93
Changes from 1 commit
5a5b3d3
ec54447
a2bcc2d
f72e5ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,18 +1,23 @@ | ||
| import json | ||
| import pprint | ||
| import traceback | ||
| from urllib.parse import urlencode | ||
|
|
||
| import structlog | ||
| from channels.generic.websocket import WebsocketConsumer | ||
| from django.conf import settings | ||
| from django.contrib.auth import REDIRECT_FIELD_NAME | ||
| from django.template.loader import render_to_string | ||
| from gspread.exceptions import SpreadsheetNotFound | ||
| from django.urls import reverse | ||
| from google.auth.exceptions import RefreshError | ||
| from gspread.exceptions import APIError, SpreadsheetNotFound | ||
| from gspread.utils import extract_id_from_url | ||
| from requests import Response | ||
|
|
||
| from apps.publish_mdm.models import FormTemplate | ||
|
|
||
| from .etl.load import PublishTemplateEvent, publish_form_template | ||
| from .utils import get_login_url | ||
|
|
||
| logger = structlog.getLogger(__name__) | ||
|
|
||
|
|
@@ -58,15 +63,81 @@ def receive(self, text_data): | |
| logger.exception("Error publishing form") | ||
| tbe = traceback.TracebackException.from_exception(exc=e, compact=True) | ||
| message = "".join(tbe.format()) | ||
| summary = None | ||
| if isinstance(e, SpreadsheetNotFound): | ||
| summary = self.get_google_picker(form_template_id=event_data.get("form_template")) | ||
| summary = self.get_error_summary(e, event_data) | ||
| # If the error is from ODK Central, format the error message for easier reading | ||
| if len(e.args) >= 2 and isinstance(e.args[1], Response): | ||
| data = e.args[1].json() | ||
| message = f"ODK Central error:\n\n{pprint.pformat(data)}\n\n{message}" | ||
| self.send_message(message, error=True, error_summary=summary) | ||
|
|
||
| def get_error_summary(self, exc: Exception, event_data: dict): | ||
| """For some exceptions, add a helpful message or instructions that will be | ||
| displayed above the traceback. | ||
| """ | ||
| if isinstance(exc, SpreadsheetNotFound): | ||
| # User does has not authorized us to access the file using their credentials. | ||
| # Display a message to that effect and a button for them to give us access | ||
| # using the Google Picker | ||
| return self.get_google_picker(form_template_id=event_data.get("form_template")) | ||
|
|
||
| error_message = None | ||
| button = None | ||
|
|
||
| if (is_refresh_error := isinstance(exc, RefreshError)) or ( | ||
| # gspread raises an APIError, catches it, then does `raise PermissionError from ...` | ||
| isinstance(exc, PermissionError) and isinstance(exc.__context__, APIError) | ||
| ): | ||
| if ( | ||
| is_refresh_error | ||
| or "Request had insufficient authentication scopes" | ||
| in exc.__context__.error["message"] | ||
| ): | ||
| # Either an expired/invalid refresh token, or the user did not | ||
| # check the checkbox to give us access to their Google Drive files | ||
| # when they first logged in. Ask them to log in again | ||
| error_message = ( | ||
| "Sorry, you need to log in again to be able to publish. " | ||
| "Please click the button below." | ||
| ) | ||
| form_template = FormTemplate.objects.get(id=event_data.get("form_template")) | ||
|
||
| publish_url = reverse( | ||
| "publish_mdm:form-template-publish", | ||
| args=[ | ||
| form_template.project.organization.slug, | ||
| form_template.project.id, | ||
| form_template.id, | ||
| ], | ||
| ) | ||
| # Add a link that will log them out then redirect to the login page. | ||
| # User will be taken through the OAuth flow again then redirected | ||
| # back to the publish page | ||
| logout_url = reverse("account_logout") | ||
| login_url = get_login_url(publish_url) | ||
| querystring = urlencode({REDIRECT_FIELD_NAME: login_url}) | ||
| button = { | ||
| "href": f"{logout_url}?{querystring}", | ||
| "text": "Log in again", | ||
| } | ||
| elif "The caller does not have permission" in exc.__context__.error["message"]: | ||
| # User does not have access to the file in Google Sheets. | ||
| # Display instructions on how to confirm if they have access | ||
| error_message = ( | ||
| "Unfortunately, we could not access the form in Google Sheets. " | ||
| 'Click the button below to access the Spreadsheet, click "Share" ' | ||
| "(or ask someone with permission to do so), and confirm the " | ||
| f'Google user "{self.scope["user"].email}" appears in the list of ' | ||
| "people with access." | ||
| ) | ||
| form_template = FormTemplate.objects.get(id=event_data.get("form_template")) | ||
|
||
| button = {"href": form_template.template_url, "text": "Open spreadsheet"} | ||
|
|
||
| if error_message or button: | ||
| context = { | ||
| "error_message": error_message, | ||
| "button": button, | ||
| } | ||
| return render_to_string("publish_mdm/ws/form_template_error_summary.html", context) | ||
|
|
||
| def publish_form_template(self, event_data: dict): | ||
| """Publish a form template to ODK Central and stream progress to the browser.""" | ||
| # Parse the event data and raise an error if it's invalid | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,41 @@ | ||
| import os | ||
| from urllib.parse import urlsplit, urlunsplit | ||
|
|
||
| from django.conf import settings | ||
| from django.contrib.auth import REDIRECT_FIELD_NAME | ||
| from django.http import QueryDict | ||
| from django.shortcuts import resolve_url | ||
|
|
||
|
|
||
| def get_secret(key): | ||
| """Get a value either from the SECRETS setting (populated from a file) or | ||
| from environment variables. | ||
| """ | ||
| return settings.SECRETS.get(key, os.getenv(key)) | ||
|
|
||
|
|
||
| def get_login_url( | ||
| next, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME, force_oauth_flow=True | ||
| ): | ||
| """ | ||
| Get a login URL that would redirect the user to the login page, passing the | ||
| given 'next' page. | ||
|
|
||
| Like django.contrib.auth.views.redirect_to_login, but adds force_oauth_flow | ||
| and returns a URL instead of a HttpResponseRedirect object. | ||
|
|
||
| force_oauth_flow will force the user to go through the Google OAuth flow | ||
| even if they had logged in before. | ||
| """ | ||
| resolved_url = resolve_url(login_url or settings.LOGIN_URL) | ||
|
|
||
| login_url_parts = list(urlsplit(resolved_url)) | ||
| if redirect_field_name or force_oauth_flow: | ||
| querystring = QueryDict(login_url_parts[3], mutable=True) | ||
| if force_oauth_flow: | ||
| querystring["auth_params"] = "prompt=select_account consent" | ||
| if redirect_field_name: | ||
| querystring[redirect_field_name] = next | ||
| login_url_parts[3] = querystring.urlencode(safe="/") | ||
|
|
||
| return urlunsplit(login_url_parts) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <div id="form-access-error" class="mb-1"> | ||
| <p class="mb-1">{{ error_message|safe }}</p> | ||
| {% if button %} | ||
| <a href="{{ button.href }}" | ||
| type="button" | ||
| class="btn btn-outline px-2 py-1 inline-block" | ||
| {% if button.text == "Open spreadsheet" %}target="_blank"{% endif %}>{{ button.text }}</a> | ||
| {% endif %} | ||
| </div> |
Uh oh!
There was an error while loading. Please reload this page.