Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 75 additions & 4 deletions apps/publish_mdm/consumers.py
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__)

Expand Down Expand Up @@ -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"))
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form_template object is retrieved twice using FormTemplate.objects.get(). Consider retrieving it once at the beginning of the method and reusing it to avoid redundant database queries.

Copilot uses AI. Check for mistakes.
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"))
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form_template object is retrieved twice using FormTemplate.objects.get(). Consider retrieving it once at the beginning of the method and reusing it to avoid redundant database queries.

Copilot uses AI. Check for mistakes.
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
Expand Down
31 changes: 31 additions & 0 deletions apps/publish_mdm/utils.py
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)
11 changes: 4 additions & 7 deletions apps/publish_mdm/views.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import json
from urllib.parse import urlencode

import structlog
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout, REDIRECT_FIELD_NAME
from django.contrib.auth import logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.views import redirect_to_login
from django.contrib.postgres.aggregates import ArrayAgg
from django.db import models, transaction
from django.db.models import OuterRef, Q, Subquery, Value
from django.db.models.functions import Collate, Lower, NullIf
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render, resolve_url
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.html import mark_safe
from django.utils.timezone import localdate
from django_tables2.config import RequestConfig
Expand Down Expand Up @@ -89,6 +87,7 @@
FormTemplateTable,
FormTemplateVersionTable,
)
from .utils import get_login_url


logger = structlog.getLogger(__name__)
Expand Down Expand Up @@ -230,9 +229,7 @@ def form_template_publish(
# OAuth consent flow again
logout(request)
messages.error(request, "Sorry, you need to log in again to be able to publish.")
querystring = urlencode({"auth_params": "prompt=select_account consent"})
login_url = f"{resolve_url(settings.LOGIN_URL)}?{querystring}"
return redirect_to_login(request.path, login_url, REDIRECT_FIELD_NAME)
return redirect(get_login_url(request.path))

form_template: FormTemplate = get_object_or_404(
request.odk_project.form_templates, pk=form_template_id
Expand Down
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>
Loading