* Update Financial Assistance logic

Use the zendesk proxy app instead of the unsupported zendesk library.

* Move to pre-fetching the group IDs.

Rather than making extra requests to zendesk to list all groups and find
a specific group ID. Just make a pre-filled list of group IDs for the
groups we care about.  When a group name is passed in, it is checked
against this list and the ticket is created in the correct group so the
right people can respond to it.
This commit is contained in:
Ayub
2019-08-16 22:05:35 +05:00
committed by Feanil Patel
parent f5f875401a
commit 658cd5c62e
11 changed files with 155 additions and 309 deletions

View File

@@ -1844,6 +1844,10 @@ ZENDESK_USER = None
ZENDESK_API_KEY = None
ZENDESK_CUSTOM_FIELDS = {}
ZENDESK_OAUTH_ACCESS_TOKEN = ''
# A mapping of string names to Zendesk Group IDs
# To get the IDs of your groups you can go to
# {zendesk_url}/api/v2/groups.json
ZENDESK_GROUP_ID_MAPPING = {}
############## Settings for Completion API #########################

View File

@@ -7,11 +7,9 @@ from functools import wraps
import calc
import crum
import zendesk
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.core.cache import caches
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseServerError
from django.views.decorators.csrf import requires_csrf_token
from django.views.defaults import server_error
from opaque_keys import InvalidKeyError
@@ -20,8 +18,6 @@ from six.moves import map
import track.views
from edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from student.models import CourseEnrollment
from student.roles import GlobalStaff
log = logging.getLogger(__name__)
@@ -168,232 +164,6 @@ def calculate(request):
return HttpResponse(json.dumps({'result': str(result)}))
class _ZendeskApi(object):
CACHE_PREFIX = 'ZENDESK_API_CACHE'
CACHE_TIMEOUT = 60 * 60
def __init__(self):
"""
Instantiate the Zendesk API.
All of `ZENDESK_URL`, `ZENDESK_USER`, and `ZENDESK_API_KEY` must be set
in `django.conf.settings`.
"""
self._zendesk_instance = zendesk.Zendesk(
settings.ZENDESK_URL,
settings.ZENDESK_USER,
settings.ZENDESK_API_KEY,
use_api_token=True,
api_version=2,
# As of 2012-05-08, Zendesk is using a CA that is not
# installed on our servers
client_args={"disable_ssl_certificate_validation": True}
)
def create_ticket(self, ticket):
"""
Create the given `ticket` in Zendesk.
The ticket should have the format specified by the zendesk package.
"""
ticket_url = self._zendesk_instance.create_ticket(data=ticket)
return zendesk.get_id_from_url(ticket_url)
def update_ticket(self, ticket_id, update):
"""
Update the Zendesk ticket with id `ticket_id` using the given `update`.
The update should have the format specified by the zendesk package.
"""
self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update)
def get_group(self, name):
"""
Find the Zendesk group named `name`. Groups are cached for
CACHE_TIMEOUT seconds.
If a matching group exists, it is returned as a dictionary
with the format specifed by the zendesk package.
Otherwise, returns None.
"""
cache = caches['default']
cache_key = '{prefix}_group_{name}'.format(prefix=self.CACHE_PREFIX, name=name)
cached = cache.get(cache_key)
if cached:
return cached
groups = self._zendesk_instance.list_groups()['groups']
for group in groups:
if group['name'] == name:
cache.set(cache_key, group, self.CACHE_TIMEOUT)
return group
return None
def _get_zendesk_custom_field_context(request, **kwargs):
"""
Construct a dictionary of data that can be stored in Zendesk custom fields.
"""
context = {}
course_id = request.POST.get("course_id")
if not course_id:
return context
context["course_id"] = course_id
if not request.user.is_authenticated:
return context
enrollment = CourseEnrollment.get_enrollment(request.user, CourseKey.from_string(course_id))
if enrollment and enrollment.is_active:
context["enrollment_mode"] = enrollment.mode
enterprise_learner_data = kwargs.get('learner_data', None)
if enterprise_learner_data:
enterprise_customer_name = enterprise_learner_data[0]['enterprise_customer']['name']
context["enterprise_customer_name"] = enterprise_customer_name
return context
def _format_zendesk_custom_fields(context):
"""
Format the data in `context` for compatibility with the Zendesk API.
Ignore any keys that have not been configured in `ZENDESK_CUSTOM_FIELDS`.
"""
custom_fields = []
for key, val, in settings.ZENDESK_CUSTOM_FIELDS.items():
if key in context:
custom_fields.append({"id": val, "value": context[key]})
return custom_fields
def _record_feedback_in_zendesk(
realname,
email,
subject,
details,
tags,
additional_info,
group_name=None,
require_update=False,
support_email=None,
custom_fields=None
):
"""
Create a new user-requested Zendesk ticket.
Once created, the ticket will be updated with a private comment containing
additional information from the browser and server, such as HTTP headers
and user state. Returns a boolean value indicating whether ticket creation
was successful, regardless of whether the private comment update succeeded.
If `group_name` is provided, attaches the ticket to the matching Zendesk group.
If `require_update` is provided, returns False when the update does not
succeed. This allows using the private comment to add necessary information
which the user will not see in followup emails from support.
If `custom_fields` is provided, submits data to those fields in Zendesk.
"""
zendesk_api = _ZendeskApi()
additional_info_string = (
u"Additional information:\n\n" +
u"\n".join(u"%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
)
# Tag all issues with LMS to distinguish channel in Zendesk; requested by student support team
zendesk_tags = list(tags.values()) + ["LMS"]
# Per edX support, we would like to be able to route feedback items by site via tagging
current_site_name = configuration_helpers.get_value("SITE_NAME")
if current_site_name:
current_site_name = current_site_name.replace(".", "_")
zendesk_tags.append("site_name_{site}".format(site=current_site_name))
new_ticket = {
"ticket": {
"requester": {"name": realname, "email": email},
"subject": subject,
"comment": {"body": details},
"tags": zendesk_tags
}
}
if custom_fields:
new_ticket["ticket"]["custom_fields"] = custom_fields
group = None
if group_name is not None:
group = zendesk_api.get_group(group_name)
if group is not None:
new_ticket['ticket']['group_id'] = group['id']
if support_email is not None:
# If we do not include the `recipient` key here, Zendesk will default to using its default reply
# email address when support agents respond to tickets. By setting the `recipient` key here,
# we can ensure that WL site users are responded to via the correct Zendesk support email address.
new_ticket['ticket']['recipient'] = support_email
try:
ticket_id = zendesk_api.create_ticket(new_ticket)
if group_name is not None and group is None:
# Support uses Zendesk groups to track tickets. In case we
# haven't been able to correctly group this ticket, log its ID
# so it can be found later.
log.warning('Unable to find group named %s for Zendesk ticket with ID %s.', group_name, ticket_id)
except zendesk.ZendeskError:
log.exception("Error creating Zendesk ticket")
return False
# Additional information is provided as a private update so the information
# is not visible to the user.
ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}}
try:
zendesk_api.update_ticket(ticket_id, ticket_update)
except zendesk.ZendeskError:
log.exception("Error updating Zendesk ticket with ID %s.", ticket_id)
# The update is not strictly necessary, so do not indicate
# failure to the user unless it has been requested with
# `require_update`.
if require_update:
return False
return True
def get_feedback_form_context(request):
"""
Extract the submitted form fields to be used as a context for
feedback submission.
"""
context = {}
context["subject"] = request.POST["subject"]
context["details"] = request.POST["details"]
context["tags"] = dict(
[(tag, request.POST[tag]) for tag in ["issue_type", "course_id"] if request.POST.get(tag)]
)
context["additional_info"] = {}
if request.user.is_authenticated:
context["realname"] = request.user.profile.name
context["email"] = request.user.email
context["additional_info"]["username"] = request.user.username
else:
context["realname"] = request.POST["name"]
context["email"] = request.POST["email"]
for header, pretty in [("HTTP_REFERER", "Page"), ("HTTP_USER_AGENT", "Browser"), ("REMOTE_ADDR", "Client IP"),
("SERVER_NAME", "Host")]:
context["additional_info"][pretty] = request.META.get(header)
context["support_email"] = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL)
return context
def info(request):
''' Info page (link from main header) '''
# pylint: disable=unused-argument

View File

@@ -38,6 +38,7 @@ from xblock.fields import Scope, String
import courseware.views.views as views
import shoppingcart
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
@@ -484,11 +485,9 @@ class ViewsTestCase(ModuleStoreTestCase):
def assert_enrollment_link_present(self, is_anonymous):
"""
Prepare ecommerce checkout data and assert if the ecommerce link is contained in the response.
Arguments:
is_anonymous(bool): Tell the method to use an anonymous user or the logged in one.
_id(bool): Tell the method to either expect an id in the href or not.
"""
sku = 'TEST123'
configuration = CommerceConfiguration.objects.create(checkout_on_ecommerce_service=True)
@@ -606,7 +605,6 @@ class ViewsTestCase(ModuleStoreTestCase):
"""
Visits the about page for `course_id` and tests that both the text "Classes End", as well
as the specified `expected_end_text`, is present on the page.
If `expected_end_text` is None, verifies that the about page *does not* contain the text
"Classes End".
"""
@@ -846,8 +844,8 @@ class ViewsTestCase(ModuleStoreTestCase):
url = reverse('submit_financial_assistance_request')
return self.client.post(url, json.dumps(data), content_type='application/json')
@patch.object(views, '_record_feedback_in_zendesk')
def test_submit_financial_assistance_request(self, mock_record_feedback):
@patch.object(views, 'create_zendesk_ticket', return_value=200)
def test_submit_financial_assistance_request(self, mock_create_zendesk_ticket):
username = self.user.username
course = six.text_type(self.course_key)
legal_name = 'Jesse Pinkman'
@@ -871,10 +869,12 @@ class ViewsTestCase(ModuleStoreTestCase):
response = self._submit_financial_assistance_form(data)
self.assertEqual(response.status_code, 204)
__, __, ticket_subject, __, tags, additional_info = mock_record_feedback.call_args[0]
mocked_kwargs = mock_record_feedback.call_args[1]
group_name = mocked_kwargs['group_name']
require_update = mocked_kwargs['require_update']
__, __, ticket_subject, __ = mock_create_zendesk_ticket.call_args[0]
mocked_kwargs = mock_create_zendesk_ticket.call_args[1]
group_name = mocked_kwargs['group']
tags = mocked_kwargs['tags']
additional_info = mocked_kwargs['additional_info']
private_comment = '\n'.join(list(additional_info.values()))
for info in (country, income, reason_for_applying, goals, effort, username, legal_name, course):
self.assertIn(info, private_comment)
@@ -891,10 +891,9 @@ class ViewsTestCase(ModuleStoreTestCase):
self.assertDictContainsSubset({'course_id': course}, tags)
self.assertIn('Client IP', additional_info)
self.assertEqual(group_name, 'Financial Assistance')
self.assertTrue(require_update)
@patch.object(views, '_record_feedback_in_zendesk', return_value=False)
def test_zendesk_submission_failed(self, _mock_record_feedback):
@patch.object(views, 'create_zendesk_ticket', return_value=500)
def test_zendesk_submission_failed(self, _mock_create_zendesk_ticket):
response = self._submit_financial_assistance_form({
'username': self.user.username,
'course': six.text_type(self.course.id),
@@ -1029,7 +1028,6 @@ class BaseDueDateTests(ModuleStoreTestCase):
def set_up_course(self, **course_kwargs):
"""
Create a stock course with a specific due date.
:param course_kwargs: All kwargs are passed to through to the :class:`CourseFactory`
"""
course = CourseFactory.create(**course_kwargs)
@@ -1158,7 +1156,6 @@ class StartDateTests(ModuleStoreTestCase):
def set_up_course(self):
"""
Create a stock course with a specific due date.
:param course_kwargs: All kwargs are passed to through to the :class:`CourseFactory`
"""
course = CourseFactory.create(start=datetime(2013, 9, 16, 7, 17, 28))
@@ -1291,7 +1288,6 @@ class ProgressPageTests(ProgressPageBaseTests):
def test_unenrolled_student_progress_for_credit_course(self, default_store):
"""
Test that student progress page does not break while checking for an unenrolled student.
Scenario: When instructor checks the progress of a student who is not enrolled in credit course.
It should return 200 response.
"""
@@ -2746,7 +2742,6 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase):
def test_vertical_positions(self, input_position, expected_position):
"""
Tests the following cases:
* Load first position when negative position inputted.
* Load first position when 0/-0 position inputted.
* Load given position when 0 < input_position <= num_positions_available.
@@ -2971,7 +2966,6 @@ class TestRenderXBlockSelfPaced(TestRenderXBlock):
class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase):
"""
Ensure that courseware index requests do not trigger student state writes.
This is to prevent locking issues that have caused latency spikes in the
courseware_studentmodule table when concurrent requests each try to update
the same rows for sequence, section, and course positions.

View File

@@ -94,6 +94,7 @@ from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
from openedx.features.course_experience import (
@@ -112,7 +113,7 @@ from track import segment
from util.cache import cache, cache_if_anonymous
from util.db import outer_atomic
from util.milestones_helpers import get_prerequisite_courses_display
from util.views import _record_feedback_in_zendesk, ensure_valid_course_key, ensure_valid_usage_key
from util.views import ensure_valid_course_key, ensure_valid_usage_key
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
@@ -287,9 +288,7 @@ def jump_to_id(request, course_id, module_id):
def jump_to(_request, course_id, location):
"""
Show the page that contains a specific location.
If the location is invalid or not in any class, return a 404.
Otherwise, delegates to the index view to figure out whether this user
has access, and what they should see.
"""
@@ -314,7 +313,6 @@ def jump_to(_request, course_id, location):
def course_info(request, course_id):
"""
Display the course's info.html, or 404 if there is no such course.
Assumes the course_id is in a valid format.
"""
# TODO: LEARNER-611: This can be deleted with Course Info removal. The new
@@ -709,7 +707,6 @@ class CourseTabView(EdxFragmentView):
def syllabus(request, course_id):
"""
Display the course's syllabus.html, or 404 if there is no such course.
Assumes the course_id is in a valid format.
"""
@@ -739,15 +736,12 @@ def registered_for_course(course, user):
class EnrollStaffView(View):
"""
Displays view for registering in the course to a global staff user.
User can either choose to 'Enroll' or 'Don't Enroll' in the course.
Enroll: Enrolls user in course and redirects to the courseware.
Don't Enroll: Redirects user to course about page.
Arguments:
- request : HTTP request
- course_id : course id
Returns:
- RedirectResponse
"""
@@ -978,9 +972,7 @@ def progress(request, course_id, student_id=None):
def _progress(request, course_key, student_id):
"""
Unwrapped version of "progress".
User progress. We show the grade bar and every problem score.
Course staff are allowed to see the progress of students in their class.
"""
@@ -1109,13 +1101,11 @@ def _certificate_message(student, course, enrollment_mode):
def _get_cert_data(student, course, enrollment_mode, course_grade=None):
"""Returns students course certificate related data.
Arguments:
student (User): Student for whom certificate to retrieve.
course (Course): Course object for which certificate data to retrieve.
enrollment_mode (String): Course mode in which student is enrolled.
course_grade (CourseGrade): Student's course grade record.
Returns:
returns dict if course certificate is available else None.
"""
@@ -1134,14 +1124,11 @@ def _get_cert_data(student, course, enrollment_mode, course_grade=None):
def _credit_course_requirements(course_key, student):
"""Return information about which credit requirements a user has satisfied.
Arguments:
course_key (CourseKey): Identifier for the course.
student (User): Currently logged in user.
Returns: dict if the credit eligibility enabled and it is a credit course
and the user is enrolled in either verified or credit mode, and None otherwise.
"""
# If credit eligibility is not enabled or this is not a credit course,
# short-circuit and return `None`. This indicates that credit requirements
@@ -1198,7 +1185,6 @@ def _course_home_redirect_enabled():
"""
Return True value if user needs to be redirected to course home based on value of
`ENABLE_MKTG_SITE` and `ENABLE_COURSE_HOME_REDIRECT feature` flags
Returns: boolean True or False
"""
if configuration_helpers.get_value(
@@ -1318,16 +1304,13 @@ def get_static_tab_fragment(request, course, tab):
def get_course_lti_endpoints(request, course_id):
"""
View that, given a course_id, returns the a JSON object that enumerates all of the LTI endpoints for that course.
The LTI 2.0 result service spec at
http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html
says "This specification document does not prescribe a method for discovering the endpoint URLs." This view
function implements one way of discovering these endpoints, returning a JSON array when accessed.
Arguments:
request (django request object): the HTTP request object that triggered this view function
course_id (unicode): id associated with the course
Returns:
(django response object): HTTP response. 404 if course is not found, otherwise 200 with JSON body.
"""
@@ -1404,12 +1387,10 @@ def course_survey(request, course_id):
def is_course_passed(student, course, course_grade=None):
"""
check user's course passing status. return True if passed
Arguments:
student : user object
course : course object
course_grade (CourseGrade) : contains student grade details.
Returns:
returns bool value
"""
@@ -1423,24 +1404,19 @@ def is_course_passed(student, course, course_grade=None):
@require_POST
def generate_user_cert(request, course_id):
"""Start generating a new certificate for the user.
Certificate generation is allowed if:
* The user has passed the course, and
* The user does not already have a pending/completed certificate.
Note that if an error occurs during certificate generation
(for example, if the queue is down), then we simply mark the
certificate generation task status as "error" and re-run
the task with a management command. To students, the certificate
will appear to be "generating" until it is re-run.
Args:
request (HttpRequest): The POST request to this view.
course_id (unicode): The identifier for the course.
Returns:
HttpResponse: 200 on success, 400 if a new certificate cannot be generated.
"""
if not request.user.is_authenticated:
@@ -1491,13 +1467,11 @@ def generate_user_cert(request, course_id):
def _track_successful_certificate_generation(user_id, course_id):
"""
Track a successful certificate generation event.
Arguments:
user_id (str): The ID of the user generating the certificate.
course_id (CourseKey): Identifier for the course.
Returns:
None
"""
event_name = 'edx.bi.user.certificate.generate'
segment.track(user_id, event_name, {
@@ -1632,7 +1606,7 @@ def financial_assistance_request(request):
# Thrown if fields are missing
return HttpResponseBadRequest(u'The field {} is required.'.format(text_type(err)))
zendesk_submitted = _record_feedback_in_zendesk(
zendesk_submitted = create_zendesk_ticket(
legal_name,
email,
u'Financial assistance request for learner {username} in course {course_name}'.format(
@@ -1640,12 +1614,12 @@ def financial_assistance_request(request):
course_name=course.display_name
),
u'Financial Assistance Request',
{'course_id': course_id},
tags={'course_id': course_id},
# Send the application as additional info on the ticket so
# that it is not shown when support replies. This uses
# OrderedDict so that information is presented in the right
# order.
OrderedDict((
additional_info=OrderedDict((
('Username', username),
('Full Name', legal_name),
('Course ID', course_id),
@@ -1657,11 +1631,9 @@ def financial_assistance_request(request):
(FA_EFFORT_LABEL, '\n' + effort + '\n\n'),
('Client IP', ip_address),
)),
group_name='Financial Assistance',
require_update=True
group='Financial Assistance',
)
if not zendesk_submitted:
if not (zendesk_submitted == 200 or zendesk_submitted == 201):
# The call to Zendesk failed. The frontend will display a
# message to the user.
return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -1329,6 +1329,10 @@ ZENDESK_USER = ''
ZENDESK_API_KEY = ''
ZENDESK_CUSTOM_FIELDS = {}
ZENDESK_OAUTH_ACCESS_TOKEN = ''
# A mapping of string names to Zendesk Group IDs
# To get the IDs of your groups you can go to
# {zendesk_url}/api/v2/groups.json
ZENDESK_GROUP_ID_MAPPING = {}
##### EMBARGO #####
EMBARGO_SITE_REDIRECT_URL = None

View File

@@ -3,10 +3,14 @@ Tests of Zendesk interaction utility functions
"""
from __future__ import absolute_import
import ddt
from django.test.utils import override_settings
from mock import MagicMock, patch
import json
from collections import OrderedDict
from django.test.utils import override_settings
import ddt
from mock import MagicMock, patch
from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket
from openedx.core.lib.api.test_utils import ApiTestCase
@@ -14,7 +18,8 @@ from openedx.core.lib.api.test_utils import ApiTestCase
@ddt.ddt
@override_settings(
ZENDESK_URL="https://www.superrealurlsthataredefinitelynotfake.com",
ZENDESK_OAUTH_ACCESS_TOKEN="abcdefghijklmnopqrstuvwxyz1234567890"
ZENDESK_OAUTH_ACCESS_TOKEN="abcdefghijklmnopqrstuvwxyz1234567890",
ZENDESK_GROUP_ID_MAPPING={"Financial Assistance": 123},
)
class TestUtils(ApiTestCase):
def setUp(self):
@@ -61,3 +66,32 @@ class TestUtils(ApiTestCase):
body=self.request_data['body'],
)
self.assertEqual(status_code, 500)
def test_financial_assistant_ticket(self):
""" Test Financial Assistent request ticket. """
ticket_creation_response_data = {
"ticket": {
"id": 35436,
"subject": "My printer is on fire!",
}
}
response_text = json.dumps(ticket_creation_response_data)
with patch('requests.post', return_value=MagicMock(status_code=200, text=response_text)):
with patch('requests.put', return_value=MagicMock(status_code=200)):
status_code = create_zendesk_ticket(
requester_name=self.request_data['name'],
requester_email=self.request_data['email'],
subject=self.request_data['subject'],
body=self.request_data['body'],
group='Financial Assistance',
additional_info=OrderedDict(
(
('Username', 'test'),
('Full Name', 'Legal Name'),
('Course ID', 'course_key'),
('Annual Household Income', 'Income'),
('Country', 'Country'),
)
),
)
self.assertEqual(status_code, 200)

View File

@@ -3,29 +3,44 @@ Utility functions for zendesk interaction.
"""
from __future__ import absolute_import
import json
import logging
from six.moves.urllib.parse import urljoin # pylint: disable=import-error
from django.conf import settings
import requests
from django.conf import settings
from rest_framework import status
from six.moves.urllib.parse import urljoin # pylint: disable=import-error
log = logging.getLogger(__name__)
def create_zendesk_ticket(requester_name, requester_email, subject, body, custom_fields=None, uploads=None, tags=None):
def _std_error_message(details, payload):
"""Internal helper to standardize error message. This allows for simpler splunk alerts."""
return u'zendesk_proxy action required\n{}\nNo ticket created for payload {}'.format(details, payload)
def _get_request_headers():
return {
'content-type': 'application/json',
'Authorization': u"Bearer {}".format(settings.ZENDESK_OAUTH_ACCESS_TOKEN),
}
def create_zendesk_ticket(
requester_name,
requester_email,
subject,
body,
group=None,
custom_fields=None,
uploads=None,
tags=None,
additional_info=None
):
"""
Create a Zendesk ticket via API.
Note that we do this differently in other locations (lms/djangoapps/commerce/signals.py and
common/djangoapps/util/views.py). Both of those callers use basic auth, and should be switched over to this oauth
implementation once the immediate pressures of zendesk_proxy are resolved.
"""
def _std_error_message(details, payload):
"""Internal helper to standardize error message. This allows for simpler splunk alerts."""
return u'zendesk_proxy action required\n{}\nNo ticket created for payload {}'.format(details, payload)
if tags:
# Remove duplicates from tags list
tags = list(set(tags))
@@ -53,15 +68,20 @@ def create_zendesk_ticket(requester_name, requester_email, subject, body, custom
log.error(_std_error_message("zendesk not configured", payload))
return status.HTTP_503_SERVICE_UNAVAILABLE
if group:
if group in settings.ZENDESK_GROUP_ID_MAPPING:
group_id = settings.ZENDESK_GROUP_ID_MAPPING[group]
data['ticket']['group_id'] = group_id
else:
msg = u"Group ID not found for group {}. Please update ZENDESK_GROUP_ID_MAPPING".format(group)
log.error(_std_error_message(msg, payload))
return status.HTTP_400_BAD_REQUEST
# Set the request parameters
url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json')
headers = {
'content-type': 'application/json',
'Authorization': u"Bearer {}".format(settings.ZENDESK_OAUTH_ACCESS_TOKEN),
}
try:
response = requests.post(url, data=payload, headers=headers)
response = requests.post(url, data=payload, headers=_get_request_headers())
# Check for HTTP codes other than 201 (Created)
if response.status_code == status.HTTP_201_CREATED:
@@ -73,7 +93,59 @@ def create_zendesk_ticket(requester_name, requester_email, subject, body, custom
payload
)
)
if additional_info:
try:
ticket = response.json()['ticket']
except (ValueError, KeyError):
log.error(
_std_error_message(
u"Got an unexpected response from zendesk api. Can't"
u" get the ticket number to add extra info. {}".format(additional_info),
response.content
)
)
return status.HTTP_400_BAD_REQUEST
return post_additional_info_as_comment(ticket['id'], additional_info)
return response.status_code
except Exception: # pylint: disable=broad-except
log.exception(_std_error_message('Internal server error', payload))
return status.HTTP_500_INTERNAL_SERVER_ERROR
def post_additional_info_as_comment(ticket_id, additional_info):
"""
Post the Additional Provided as a comment, So that it is only visible
to management and not students.
"""
additional_info_string = (
u"Additional information:\n\n" +
u"\n".join(u"%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None)
)
data = {
'ticket': {
'comment': {
'body': additional_info_string,
'public': False
}
}
}
url = urljoin(settings.ZENDESK_URL, 'api/v2/tickets/{}.json'.format(ticket_id))
try:
response = requests.put(url, data=json.dumps(data), headers=_get_request_headers())
if response.status_code == 200:
log.debug(u'Successfully created comment for ticket {}'.format(ticket_id))
else:
log.error(
_std_error_message(
u'Unexpected response: {} - {}'.format(response.status_code, response.content),
data
)
)
return response.status_code
except Exception: # pylint: disable=broad-except
log.exception(_std_error_message('Internal server error', data))
return status.HTTP_500_INTERNAL_SERVER_ERROR

View File

@@ -154,6 +154,5 @@ web-fragments # Provides the ability to render fragments o
XBlock # Courseware component architecture
xblock-utils # Provides utilities used by the Discussion XBlock
xss-utils # https://github.com/edx/edx-platform/pull/20633 Fix XSS via Translations
zendesk # Python API for the Zendesk customer support system
geoip2==2.9.0 # Python API for the GeoIP web services and databases
edx-bulk-grades # LMS REST API for managing bulk grading operations

View File

@@ -135,7 +135,7 @@ glob2==0.7
gunicorn==19.9.0
help-tokens==1.0.4
html5lib==1.0.1
httplib2==0.13.1 # via zendesk
httplib2==0.13.1
idna==2.8
inflection==0.3.1 # via drf-yasg
ipaddress==1.0.22
@@ -223,7 +223,7 @@ scipy==1.2.1
semantic-version==2.6.0 # via edx-drf-extensions
shapely==1.6.4.post2
shortuuid==0.5.0 # via edx-django-oauth2-provider
simplejson==3.16.0 # via mailsnake, sailthru-client, zendesk
simplejson==3.16.0 # via mailsnake, sailthru-client
singledispatch==3.4.0.3
six==1.11.0
slumber==0.7.1 # via edx-bulk-grades, edx-enterprise, edx-rest-api-client
@@ -255,7 +255,6 @@ xblock-utils==1.2.2
xblock==1.2.3
xmlsec==1.3.3 # via python3-saml
xss-utils==0.1.1
zendesk==1.1.1
# The following packages are considered to be unsafe in a requirements file:
# setuptools==41.1.0 # via fs, lazy, python-levenshtein

View File

@@ -349,7 +349,6 @@ xblock==1.2.3
xmlsec==1.3.3
xmltodict==0.12.0
xss-utils==0.1.1
zendesk==1.1.1
zipp==0.5.2
# The following packages are considered to be unsafe in a requirements file:

View File

@@ -335,7 +335,6 @@ xblock==1.2.3
xmlsec==1.3.3
xmltodict==0.12.0 # via moto
xss-utils==0.1.1
zendesk==1.1.1
zipp==0.5.2 # via importlib-metadata
# The following packages are considered to be unsafe in a requirements file: