Files
edx-platform/lms/djangoapps/commerce/utils.py
2019-02-05 15:15:02 -05:00

409 lines
15 KiB
Python

"""Utilities to assist with commerce tasks."""
import json
import logging
from urllib import urlencode
from urlparse import urljoin
import requests
import waffle
from django.conf import settings
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils.translation import ugettext as _
from opaque_keys.edx.keys import CourseKey
from course_modes.models import CourseMode
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
from student.models import CourseEnrollment
from .models import CommerceConfiguration
log = logging.getLogger(__name__)
def is_account_activation_requirement_disabled():
"""
Checks to see if the django-waffle switch for disabling the account activation requirement is active
Returns:
Boolean value representing switch status
"""
switch_name = configuration_helpers.get_value(
'DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH',
settings.DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH
)
return waffle.switch_is_active(switch_name)
class EcommerceService(object):
""" Helper class for ecommerce service integration. """
def __init__(self):
self.config = CommerceConfiguration.current()
@property
def ecommerce_url_root(self):
""" Retrieve Ecommerce service public url root. """
return configuration_helpers.get_value('ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT)
def get_absolute_ecommerce_url(self, ecommerce_page_url):
""" Return the absolute URL to the ecommerce page.
Args:
ecommerce_page_url (str): Relative path to the ecommerce page.
Returns:
Absolute path to the ecommerce page.
"""
return urljoin(self.ecommerce_url_root, ecommerce_page_url)
def get_order_dashboard_url(self):
""" Return the URL to the ecommerce dashboard orders page.
Returns:
String: order dashboard url.
"""
return self.get_absolute_ecommerce_url(CommerceConfiguration.DEFAULT_ORDER_DASHBOARD_URL)
def get_receipt_page_url(self, order_number):
"""
Gets the URL for the Order Receipt page hosted by the ecommerce service.
Args:
order_number (str): Order number.
Returns:
Receipt page for the specified Order.
"""
return self.get_absolute_ecommerce_url(CommerceConfiguration.DEFAULT_RECEIPT_PAGE_URL + order_number)
def is_enabled(self, user):
"""
Determines the availability of the EcommerceService based on user activation and service configuration.
Note: If the user is anonymous we bypass the user activation gate and only look at the service config.
Returns:
Boolean
"""
user_is_active = user.is_active or is_account_activation_requirement_disabled()
allow_user = user_is_active or user.is_anonymous
return allow_user and self.config.checkout_on_ecommerce_service
def payment_page_url(self):
""" Return the URL for the checkout page.
Example:
http://localhost:8002/basket/add/
"""
return self.get_absolute_ecommerce_url(self.config.basket_checkout_page)
def get_checkout_page_url(self, *skus, **kwargs):
""" Construct the URL to the ecommerce checkout page and include products.
Args:
skus (list): List of SKUs associated with products to be added to basket
program_uuid (string): The UUID of the program, if applicable
Returns:
Absolute path to the ecommerce checkout page showing basket that contains specified products.
Example:
http://localhost:8002/basket/add/?sku=5H3HG5&sku=57FHHD
http://localhost:8002/basket/add/?sku=5H3HG5&sku=57FHHD&bundle=3bdf1dd1-49be-4a15-9145-38901f578c5a
"""
program_uuid = kwargs.get('program_uuid')
enterprise_catalog_uuid = kwargs.get('catalog')
query_params = {'sku': skus}
if enterprise_catalog_uuid:
query_params.update({'catalog': enterprise_catalog_uuid})
url = '{checkout_page_path}?{query_params}'.format(
checkout_page_path=self.get_absolute_ecommerce_url(self.config.basket_checkout_page),
query_params=urlencode(query_params, doseq=True),
)
if program_uuid:
url = '{url}&bundle={program_uuid}'.format(
url=url,
program_uuid=program_uuid
)
return url
def upgrade_url(self, user, course_key):
"""
Returns the URL for the user to upgrade, or None if not applicable.
"""
verified_mode = CourseMode.verified_mode_for_course(course_key)
if verified_mode:
if self.is_enabled(user):
return self.get_checkout_page_url(verified_mode.sku)
else:
return reverse('verify_student_upgrade_and_verify', args=(course_key,))
return None
def refund_entitlement(course_entitlement):
"""
Attempt a refund of a course entitlement. Verify the User before calling this refund method
Returns:
bool: True if the Refund is successfully processed.
"""
user_model = get_user_model()
enrollee = course_entitlement.user
entitlement_uuid = str(course_entitlement.uuid)
if not is_commerce_service_configured():
log.error(
u'Ecommerce service is not configured, cannot refund for user [%s], course entitlement [%s].',
enrollee.id,
entitlement_uuid
)
return False
service_user = user_model.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
log.info(
u'Attempting to create a refund for user [%s], course entitlement [%s]...',
enrollee.id,
entitlement_uuid
)
try:
refund_ids = api_client.refunds.post(
{
'order_number': course_entitlement.order_number,
'username': enrollee.username,
'entitlement_uuid': entitlement_uuid,
}
)
except Exception as exc: # pylint: disable=broad-except
# Catch any possible exceptions from the Ecommerce service to ensure we fail gracefully
log.exception(
u"Unexpected exception while attempting to initiate refund for user [%s], "
u"course entitlement [%s] message: [%s]",
enrollee.id,
course_entitlement.uuid,
str(exc)
)
return False
if refund_ids:
log.info(
u'Refund successfully opened for user [%s], course entitlement [%s]: %r',
enrollee.id,
entitlement_uuid,
refund_ids,
)
return _process_refund(
refund_ids=refund_ids,
api_client=api_client,
mode=course_entitlement.mode,
user=enrollee,
always_notify=True,
)
else:
log.warn(u'No refund opened for user [%s], course entitlement [%s]', enrollee.id, entitlement_uuid)
return False
def refund_seat(course_enrollment, change_mode=False):
"""
Attempt to initiate a refund for any orders associated with the seat being unenrolled,
using the commerce service.
Arguments:
course_enrollment (CourseEnrollment): a student enrollment
change_mode (Boolean): change the course mode to free mode or not
Returns:
A list of the external service's IDs for any refunds that were initiated
(may be empty).
Raises:
exceptions.SlumberBaseException: for any unhandled HTTP error during communication with the E-Commerce Service.
exceptions.Timeout: if the attempt to reach the commerce service timed out.
"""
User = get_user_model() # pylint:disable=invalid-name
course_key_str = unicode(course_enrollment.course_id)
enrollee = course_enrollment.user
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
log.info(u'Attempting to create a refund for user [%s], course [%s]...', enrollee.id, course_key_str)
refund_ids = api_client.refunds.post({'course_id': course_key_str, 'username': enrollee.username})
if refund_ids:
log.info(u'Refund successfully opened for user [%s], course [%s]: %r', enrollee.id, course_key_str, refund_ids)
_process_refund(
refund_ids=refund_ids,
api_client=api_client,
mode=course_enrollment.mode,
user=enrollee,
)
if change_mode and CourseMode.can_auto_enroll(course_id=CourseKey.from_string(course_key_str)):
course_enrollment.update_enrollment(mode=CourseMode.auto_enroll_mode(course_id=course_key_str),
is_active=False, skip_refund=True)
course_enrollment.save()
else:
log.info(u'No refund opened for user [%s], course [%s]', enrollee.id, course_key_str)
return refund_ids
def _process_refund(refund_ids, api_client, mode, user, always_notify=False):
"""
Helper method to process a refund for a given course_product. This method assumes that the User has already
been unenrolled.
Arguments:
refund_ids: List of refund ids to be processed
api_client: The API Client used in the processing of refunds
mode: The mode that the refund should be processed for
user: The user that the refund is being processed for
always_notify (bool): This will enable always notifying support with Zendesk tickets when
an approval is required
Returns:
bool: True if the refund process was successful, False if there are any Errors that are not handled
"""
config = CommerceConfiguration.current()
if config.enable_automatic_refund_approval:
refunds_requiring_approval = []
for refund_id in refund_ids:
try:
# NOTE: The following assumes that the user has already been unenrolled.
# We are then able to approve payment. Additionally, this ensures we don't tie up an
# additional web worker when the E-Commerce Service tries to unenroll the learner.
api_client.refunds(refund_id).process.put({'action': 'approve_payment_only'})
log.info(u'Refund [%d] successfully approved.', refund_id)
except: # pylint: disable=bare-except
# Push the refund to Support to process
log.exception(u'Failed to automatically approve refund [%d]!', refund_id)
refunds_requiring_approval.append(refund_id)
else:
refunds_requiring_approval = refund_ids
if refunds_requiring_approval:
# XCOM-371: this is a temporary measure to suppress refund-related email
# notifications to students and support for free enrollments. This
# condition should be removed when the CourseEnrollment.refundable() logic
# is updated to be more correct, or when we implement better handling (and
# notifications) in Otto for handling reversal of $0 transactions.
if mode != 'verified' and not always_notify:
# 'verified' is the only enrollment mode that should presently
# result in opening a refund request.
log.info(
u'Skipping refund support notification for non-verified mode for user [%s], mode: [%s]',
user.id,
mode,
)
else:
try:
return _send_refund_notification(user, refunds_requiring_approval)
except: # pylint: disable=bare-except
# Unable to send notification to Support, do not break as this method is used by Signals
log.warning('Could not send support notification for refund.', exc_info=True)
return False
return True
def _send_refund_notification(user, refund_ids):
"""
Notify the support team of the refund request.
Returns:
bool: True if we are able to send the notification. In this case that means we were able to create
a ZenDesk ticket
"""
tags = ['auto_refund']
if theming_helpers.is_request_in_themed_site():
# this is not presently supported with the external service.
raise NotImplementedError("Unable to send refund processing emails to support teams.")
# Build the information for the ZenDesk ticket
student = user
subject = _("[Refund] User-Requested Refund")
body = _generate_refund_notification_body(student, refund_ids)
requester_name = student.profile.name or student.username
return create_zendesk_ticket(requester_name, student.email, subject, body, tags)
def _generate_refund_notification_body(student, refund_ids):
""" Returns a refund notification message body. """
msg = _(
u'A refund request has been initiated for {username} ({email}). '
'To process this request, please visit the link(s) below.'
).format(username=student.username, email=student.email)
ecommerce_url_root = configuration_helpers.get_value(
'ECOMMERCE_PUBLIC_URL_ROOT', settings.ECOMMERCE_PUBLIC_URL_ROOT,
)
refund_urls = [urljoin(ecommerce_url_root, '/dashboard/refunds/{}/'.format(refund_id))
for refund_id in refund_ids]
# emails contained in this message could contain unicode characters so encode as such
return u'{msg}\n\n{urls}'.format(msg=msg, urls='\n'.join(refund_urls))
def create_zendesk_ticket(requester_name, requester_email, subject, body, tags=None):
"""
Create a Zendesk ticket via API.
Returns:
bool: False if we are unable to create the ticket for any reason
"""
if not (settings.ZENDESK_URL and settings.ZENDESK_USER and settings.ZENDESK_API_KEY):
log.error('Zendesk is not configured. Cannot create a ticket.')
return False
# Copy the tags to avoid modifying the original list.
tags = set(tags or [])
tags.add('LMS')
tags = list(tags)
data = {
'ticket': {
'requester': {
'name': requester_name,
'email': unicode(requester_email)
},
'subject': subject,
'comment': {'body': body},
'tags': tags
}
}
# Encode the data to create a JSON payload
payload = json.dumps(data)
# Set the request parameters
url = urljoin(settings.ZENDESK_URL, '/api/v2/tickets.json')
user = '{}/token'.format(settings.ZENDESK_USER)
pwd = settings.ZENDESK_API_KEY
headers = {'content-type': 'application/json'}
try:
response = requests.post(url, data=payload, auth=(user, pwd), headers=headers)
# Check for HTTP codes other than 201 (Created)
if response.status_code != 201:
log.error(u'Failed to create ticket. Status: [%d], Body: [%s]', response.status_code, response.content)
return False
else:
log.debug('Successfully created ticket.')
except Exception: # pylint: disable=broad-except
log.exception('Failed to create ticket.')
return False
return True