BOM-70 (#21327)
* 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:
@@ -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 #########################
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user