Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
18 changes: 12 additions & 6 deletions muckrock/assets/components/ContactForm.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
<script>
import { showMessage } from "../js/messages";

let { flagCategory = "", csrfToken = "" } = $props();
let {
csrfToken = "",
foiaPk = "",
categoryLabel = "",
problemTitle = "",
} = $props();
let text = $state("");
let submitted = $state(false);
let errorMessage = $state("");
Expand All @@ -15,7 +20,7 @@
const formData = new FormData(form);

try {
const response = await fetch(window.location.href, {
const response = await fetch("/gethelp/contact/", {
method: "POST",
headers: {
"X-Requested-With": "XMLHttpRequest",
Expand Down Expand Up @@ -45,11 +50,12 @@

<form method="post" class="get-help__contact-form" onsubmit={handleSubmit}>
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
<input type="hidden" name="action" value="flag" />
<input type="hidden" name="flag-category" value={flagCategory} />
<input type="hidden" name="foia_pk" value={foiaPk} />
<input type="hidden" name="category_label" value={categoryLabel} />
<input type="hidden" name="problem_title" value={problemTitle} />
<textarea
id="flag-text"
name="flag-text"
id="text"
name="text"
bind:value={text}
required
rows="4"
Expand Down
3 changes: 2 additions & 1 deletion muckrock/assets/components/GetHelp.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@
<ProblemList
category={problems[selectedCategory]}
{csrfToken}
foiaPk={foiaId}
onBack={goBack}
/>
{:else if view === "contact"}
<div class="get-help__contact-view">
<h3>Contact Support</h3>
<ContactForm flagCategory="" {csrfToken} />
<ContactForm {csrfToken} foiaPk={foiaId} />
</div>
{/if}
</main>
Expand Down
8 changes: 4 additions & 4 deletions muckrock/assets/components/ProblemItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import ContactForm from "./ContactForm.svelte";
import Self from './ProblemItem.svelte';

let { problem, csrfToken = "" } = $props();
let { problem, csrfToken = "", foiaPk = "", categoryLabel = "" } = $props();
let showContact = $state(false);
</script>

Expand All @@ -18,9 +18,9 @@
{#if problem.children && problem.children.length > 0}
<div class="get-help__children">
{#each problem.children as child (child.id)}
<Self problem={child} {csrfToken} />
<Self problem={child} {csrfToken} {foiaPk} {categoryLabel} />
{/each}
<Self problem={{id: "other", title: "Other"}} {csrfToken} />
<Self problem={{id: "other", title: "Other"}} {csrfToken} {foiaPk} {categoryLabel} />
</div>
{:else}
{#if problem.resolution_html && !showContact}
Expand All @@ -32,7 +32,7 @@
I still need help
</button>
{:else}
<ContactForm flagCategory={problem.flag_category} {csrfToken} />
<ContactForm {csrfToken} {foiaPk} {categoryLabel} problemTitle={problem.title} />
{/if}
{/if}
</div>
Expand Down
6 changes: 3 additions & 3 deletions muckrock/assets/components/ProblemList.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script>
import ProblemItem from "./ProblemItem.svelte";

let { category, csrfToken = "" } = $props();
let { category, csrfToken = "", foiaPk = "" } = $props();

let otherProblem = {
title: "I need help with something else.",
Expand All @@ -12,10 +12,10 @@
<h3>{category.label}</h3>

{#each category.problems as problem (problem.id)}
<ProblemItem {problem} {csrfToken} />
<ProblemItem {problem} {csrfToken} {foiaPk} categoryLabel={category.label} />
{/each}

<ProblemItem problem={otherProblem} {csrfToken} />
<ProblemItem problem={otherProblem} {csrfToken} {foiaPk} categoryLabel={category.label} />
</div>

<style>
Expand Down
1 change: 1 addition & 0 deletions muckrock/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@
re_path(r"^project/", include("muckrock.project.urls")),
re_path(r"^fine-uploader/", include("muckrock.fine_uploader.urls")),
re_path(r"^communication/", include("muckrock.communication.urls")),
re_path(r"^gethelp/", include("muckrock.gethelp.urls")),
re_path(r"^squarelet/", include("muckrock.squarelet.urls")),
re_path(r"^admin/", admin.site.urls),
re_path(r"^search/$", views.SearchView.as_view(), name="search"),
Expand Down
67 changes: 67 additions & 0 deletions muckrock/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@
import boto3
import requests
import stripe
from zenpy import Zenpy
from zenpy.lib.api_objects import (
Comment,
Organization as ZenOrg,
Ticket,
User as ZenUser,
)
from zenpy.lib.exception import APIException

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -264,6 +272,65 @@ def zoho_get(path, params=None):
return _zoho(requests.get, path, params=params)


def get_zendesk_client():
"""Return a configured Zenpy client."""
return Zenpy(
email=settings.ZENDESK_EMAIL,
subdomain=settings.ZENDESK_SUBDOMAIN,
token=settings.ZENDESK_TOKEN,
)


def create_zendesk_ticket(
subject, description, tags, requester_data, org_data=None, custom_fields=None
):
"""Create a Zendesk ticket and return its integer ticket ID.

requester_data: dict with at least `name`; optionally `email`, `external_id`
org_data: dict with `name` + `external_id`, or None
custom_fields: list of {id, value} dicts, or None
"""
client = get_zendesk_client()

org_id = None
if org_data:
try:
org = client.organizations.create_or_update(ZenOrg(**org_data))
except APIException:
# De-duplicate name
deduped = dict(org_data) # Create new dict from org_data
deduped["name"] += "_" + deduped["external_id"][:6]
org = client.organizations.create_or_update(ZenOrg(**deduped))
org_id = org.id
if org_id:
requester_data = dict(requester_data, organization_id=org_id)

try:
user = client.users.create_or_update(ZenUser(**requester_data))
except APIException:
# merge conflicting users
user1 = list(client.users.search(external_id=requester_data["external_id"]))[0]
user2 = list(client.users.search(query=requester_data["email"]))[0]
user = client.users.merge(user1, user2)

ticket_kwargs = {
"subject": subject,
"comment": Comment(body=description),
"type": "task",
"priority": "normal",
"status": "new",
"tags": list(tags or []),
"requester_id": user.id,
}
if org_id:
ticket_kwargs["organization_id"] = org_id
if custom_fields:
ticket_kwargs["custom_fields"] = custom_fields

audit = client.tickets.create(Ticket(**ticket_kwargs))
return audit.ticket.id


def get_s3_storage_bucket():
"""Return the S3 storage bucket"""
s3 = boto3.resource("s3")
Expand Down
14 changes: 14 additions & 0 deletions muckrock/gethelp/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Forms for the gethelp app"""

# Django
from django import forms


class GetHelpForm(forms.Form):
text = forms.CharField(
strip=True,
error_messages={"required": "Please describe your issue."},
)
foia_pk = forms.IntegerField(required=False)
category_label = forms.CharField(required=False)
problem_title = forms.CharField(required=False)
147 changes: 147 additions & 0 deletions muckrock/gethelp/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Celery tasks for the gethelp app"""

# Django
from celery import shared_task
from django.conf import settings
from django.contrib.auth.models import User
from django.utils import timezone

# Standard Library
import logging
import sys
from random import randint

# Third Party
from requests.exceptions import RequestException
from zenpy.lib.exception import APIException, ZenpyException

# MuckRock
from muckrock.core.utils import create_zendesk_ticket
from muckrock.foia.models import FOIARequest
from muckrock.organization.models import Membership

logger = logging.getLogger(__name__)

MR_NUMBER_FIELD = 1500004565182


def create_ticket_subject(user, category_label, problem_title):
username = user.username if user else "anonymous"
if category_label and problem_title:
subject = f"[{category_label}] {problem_title} @{username}"
elif category_label:
subject = f"[{category_label}] @{username}"
else:
subject = f"[GetHelp] @{username}"
return subject


def get_primary_org(user, foia):
"""Return the org this ticket is associated with, or None."""
if foia:
return foia.composer.organization
try:
return user.profile.organization
except Membership.DoesNotExist:
return None


def create_ticket_description(text, user, foia):
links = ["=== Quick Links ==="]
if foia:
links.append(
f"- Request on MuckRock Requests: "
f"{settings.MUCKROCK_URL}{foia.get_absolute_url()}"
)
if user:
links.append(
f"- User profile on MuckRock Requests: "
f"{settings.MUCKROCK_URL}{user.profile.get_absolute_url()}"
)
links.append(
f"- User profile on MuckRock Accounts: "
f"{settings.SQUARELET_URL}/users/{user.username}/"
)
org = get_primary_org(user, foia)
if org and not org.individual:
plan = "Premium" if org.entitlement.base_requests > 0 else "Free"
links.append(
f"- {plan} Organization on MuckRock Requests: "
f"{settings.MUCKROCK_URL}{org.get_absolute_url()}"
)
links.append(
f"- {plan} Organization on MuckRock Accounts: "
f"{settings.SQUARELET_URL}/organizations/{org.slug}/ "
)
if links:
return text + "\n\n" + "\n".join(links)
return text


def create_ticket_tags(category_label):
tags = ["gethelp"]
if category_label:
tags.append(category_label.lower().replace(" ", "_"))
return tags


def create_ticket_data(user, foia):
if user:
requester_data = {
"name": user.profile.full_name or user.username,
"external_id": str(user.profile.uuid),
}
if user.email:
requester_data["email"] = user.email
primary_org = get_primary_org(user, foia)
if primary_org:
org_data = {
"name": primary_org.name,
"external_id": str(primary_org.uuid),
}
else:
org_data = None
else:
requester_data = {"name": "Anonymous User"}
org_data = None
return [requester_data, org_data]


@shared_task(ignore_result=True, max_retries=5)
def create_gethelp_ticket(
user_pk, text, foia_pk=None, category_label="", problem_title=""
):
"""Create a Zendesk support ticket from a GetHelp form submission."""
user = (
User.objects.filter(pk=user_pk).select_related("profile").first()
if user_pk
else None
)
foia = FOIARequest.objects.filter(pk=foia_pk).first() if foia_pk else None
[requester_data, org_data] = create_ticket_data(user, foia)
custom_fields = [{"id": MR_NUMBER_FIELD, "value": foia.pk}] if foia else None

try:
ticket_id = create_zendesk_ticket(
subject=create_ticket_subject(user, category_label, problem_title),
description=create_ticket_description(text, user, foia),
tags=create_ticket_tags(category_label),
requester_data=requester_data,
org_data=org_data,
custom_fields=custom_fields,
)
if foia and user and foia.has_perm(user, "change"):
foia.notes.create(
author=user,
datetime=timezone.now(),
note=f"Submitted help request:\n\n{text}\n\n" f"Ticket ID: {ticket_id}",
)
except (RequestException, ZenpyException, APIException) as exc:
logger.warning(
"Zendesk error in create_gethelp_ticket: %s", exc, exc_info=sys.exc_info()
)
raise create_gethelp_ticket.retry(
countdown=(2**create_gethelp_ticket.request.retries) * 300
+ randint(0, 300),
exc=exc,
)
11 changes: 11 additions & 0 deletions muckrock/gethelp/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""URL configuration for the gethelp app"""

# Django
from django.urls import path

# MuckRock
from muckrock.gethelp import views

urlpatterns = [
path("contact/", views.contact, name="gethelp-contact"),
]
Loading
Loading