diff --git a/.github/workflows/add-depr-ticket-to-depr-board.yml b/.github/workflows/add-depr-ticket-to-depr-board.yml index 73ca4c5c6e8..250e394abc1 100644 --- a/.github/workflows/add-depr-ticket-to-depr-board.yml +++ b/.github/workflows/add-depr-ticket-to-depr-board.yml @@ -16,4 +16,4 @@ jobs: secrets: GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} - SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} \ No newline at end of file + SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml new file mode 100644 index 00000000000..0f369db7d29 --- /dev/null +++ b/.github/workflows/add-remove-label-on-comment.yml @@ -0,0 +1,20 @@ +# This workflow runs when a comment is made on the ticket +# If the comment starts with "label: " it tries to apply +# the label indicated in rest of comment. +# If the comment starts with "remove label: ", it tries +# to remove the indicated label. +# Note: Labels are allowed to have spaces and this script does +# not parse spaces (as often a space is legitimate), so the command +# "label: really long lots of words label" will apply the +# label "really long lots of words label" + +name: Allows for the adding and removing of labels via comment + +on: + issue_comment: + types: [created] + +jobs: + add_remove_labels: + uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecc76075fe9..9d815b53367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,10 @@ jobs: architecture: x64 - name: Report coverage if: matrix.testname == 'test-python' - run: | - pip install codecov - codecov + uses: codecov/codecov-action@v3 + with: + flags: unittests + fail_ci_if_error: false docs: runs-on: ubuntu-latest diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000000..ddc2a944ca7 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,50 @@ +name: Build and Push Docker Images + +on: + push: + branches: + - master + - open-release/** +jobs: + push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + # Use the release name as the image tag if we're building an open release tag. + # Examples: if we're building 'open-release/olive.master', tag the image as 'olive.master'. + # Otherwise, we must be building from a push to master, so use 'latest'. + - name: Get tag name + id: get-tag-name + uses: actions/github-script@v5 + with: + script: | + const branchName = context.ref.split('/').slice(-1)[0]; + const tagName = branchName === 'master' ? 'latest' : branchName; + console.log('Will use tag: ' + tagName); + return tagName; + result-encoding: string + + - name: Build and push Dev Docker image + uses: docker/build-push-action@v1 + with: + push: true + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + target: dev + repository: edxops/ecommerce-dev + tags: ${{ steps.get-tag-name.outputs.result }},${{ github.sha }} + + # The current priority is to get the devstack off of Ansible based Images. Once that is done, we can come back to this part to get + # suitable images for smaller prod environments. + # - name: Build and push prod Docker image + # uses: docker/build-push-action@v1 + # with: + # push: true + # username: ${{ secrets.DOCKERHUB_USERNAME }} + # password: ${{ secrets.DOCKERHUB_PASSWORD }} + # target: prod + # repository: edxops/ecommerce-prod + # tags: ${{ steps.get-tag-name.outputs.result }},${{ github.sha }} diff --git a/.github/workflows/self-assign-issue.yml b/.github/workflows/self-assign-issue.yml new file mode 100644 index 00000000000..37522fd57b1 --- /dev/null +++ b/.github/workflows/self-assign-issue.yml @@ -0,0 +1,12 @@ +# This workflow runs when a comment is made on the ticket +# If the comment starts with "assign me" it assigns the author to the +# ticket (case insensitive) + +name: Assign comment author to ticket if they say "assign me" +on: + issue_comment: + types: [created] + +jobs: + self_assign_by_comment: + uses: openedx/.github/.github/workflows/self-assign-issue.yml@master diff --git a/.github/workflows/upgrade-python-requirements.yml b/.github/workflows/upgrade-python-requirements.yml index 143a378f1bd..cb4a03e8a45 100644 --- a/.github/workflows/upgrade-python-requirements.yml +++ b/.github/workflows/upgrade-python-requirements.yml @@ -2,7 +2,7 @@ name: Upgrade Requirements on: schedule: - - cron: "15 1 * * 4" + - cron: "15 1 1 * *" workflow_dispatch: inputs: branch: @@ -13,8 +13,9 @@ jobs: call-upgrade-python-requirements-workflow: with: branch: ${{ github.event.inputs.branch }} - team_reviewers: "revenue-squad" - email_address: revenue-tasks@edx.org + user_reviewers: edx-revenue-tasks + team_reviewers: "" + email_address: revenue-tasks@edx.org send_success_notification: false secrets: requirements_bot_github_token: ${{ secrets.REQUIREMENTS_BOT_GITHUB_TOKEN }} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000000..e250f03501c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +# Team @openedx/revenue-squad will be the default owners for +# everything in this repo. Unless a later match takes +# precedence, @openedx/revenue-squad will be requested for +# review when someone opens a pull request. +* @openedx/revenue-squad diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index b45bfb2963c..00000000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,4 +0,0 @@ -How To Contribute -================= - -Contributions are welcome. Please read `How To Contribute `_ for details. Even though it was written with ``edx-platform`` in mind, these guidelines should be followed for Open edX code in general. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000000..5eeb76fe686 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,89 @@ +FROM ubuntu:focal as app + +ENV DEBIAN_FRONTEND noninteractive +# System requirements. +RUN apt update && \ + apt-get install -qy \ + curl \ + git \ + language-pack-en \ + build-essential \ + python3.8-dev \ + python3-virtualenv \ + python3.8-distutils \ + libmysqlclient-dev \ + libssl-dev \ + libcairo2-dev && \ + rm -rf /var/lib/apt/lists/* + +# Use UTF-8. +RUN locale-gen en_US.UTF-8 +ENV LANG en_US.UTF-8 +ENV LANGUAGE en_US:en +ENV LC_ALL en_US.UTF-8 + +ARG COMMON_APP_DIR="/edx/app" +ARG COMMON_CFG_DIR="/edx/etc" +ARG SERVICE_NAME="ecommerce" +ARG ECOMMERCE_APP_DIR="${COMMON_APP_DIR}/${SERVICE_NAME}" +ARG ECOMMERCE_VENV_DIR="${COMMON_APP_DIR}/${SERVICE_NAME}/venvs/${SERVICE_NAME}" +ARG ECOMMERCE_CODE_DIR="${ECOMMERCE_APP_DIR}/${SERVICE_NAME}" +ARG ECOMMERCE_NODEENV_DIR="${ECOMMERCE_APP_DIR}/nodeenvs/${SERVICE_NAME}" + +ENV ECOMMERCE_CFG "${COMMON_CFG_DIR}/ecommerce.yml" +ENV ECOMMERCE_CODE_DIR "${ECOMMERCE_CODE_DIR}" +ENV ECOMMERCE_APP_DIR "${ECOMMERCE_APP_DIR}" + +# Add virtual env and node env to PATH, in order to activate them +ENV PATH "${ECOMMERCE_VENV_DIR}/bin:${ECOMMERCE_NODEENV_DIR}/bin:$PATH" + +RUN virtualenv -p python3.8 --always-copy ${ECOMMERCE_VENV_DIR} + +RUN pip install nodeenv + +RUN nodeenv ${ECOMMERCE_NODEENV_DIR} --node=16.14.0 --prebuilt && npm install -g npm@8.5.x + +# Set working directory to the root of the repo +WORKDIR ${ECOMMERCE_CODE_DIR} + +# Install JS requirements +COPY package.json package.json +COPY package-lock.json package-lock.json +COPY bower.json bower.json +RUN npm install --production && ./node_modules/.bin/bower install --allow-root --production + +# Expose canonical ecommerce port +EXPOSE 18130 + +FROM app as prod + +ENV DJANGO_SETTINGS_MODULE "ecommerce.settings.production" + +COPY requirements/production.txt ${ECOMMERCE_CODE_DIR}/requirements/production.txt + +RUN pip install -r ${ECOMMERCE_CODE_DIR}/requirements/production.txt + +# Copy over rest of code. +# We do this AFTER requirements so that the requirements cache isn't busted +# every time any bit of code is changed. +COPY . . + +CMD gunicorn --bind=0.0.0.0:18130 --workers 2 --max-requests=1000 -c ecommerce/docker_gunicorn_configuration.py ecommerce.wsgi:application + +FROM app as dev + +ENV DJANGO_SETTINGS_MODULE "ecommerce.settings.devstack" + +COPY requirements/dev.txt ${ECOMMERCE_CODE_DIR}/requirements/dev.txt + +RUN pip install -r ${ECOMMERCE_CODE_DIR}/requirements/dev.txt + +# Devstack related step for backwards compatibility +RUN touch ${ECOMMERCE_APP_DIR}/ecommerce_env + +# Copy over rest of code. +# We do this AFTER requirements so that the requirements cache isn't busted +# every time any bit of code is changed. +COPY . . + +CMD while true; do python ./manage.py runserver 0.0.0.0:18130; sleep 2; done diff --git a/README.rst b/README.rst index a71c9b21753..70417d79b5a 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -⛔️ DEPRECATION WARNING +⛔️ DEPRECATION WARNING ====================== This repository is deprecated and in maintainence-only operation while we work on a replacement, please see `this announcement `__ for more information. -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- @@ -32,7 +32,7 @@ How To Contribute Anyone merging to this repository is expected to `release and monitor their changes `__; if you are not able to do this DO NOT MERGE, please coordinate with someone who can to ensure that the changes are released. -Please also read `How To Contribute `__. Even though it was written with ``edx-platform`` in mind, these guidelines should be followed for Open edX code in general. +Please also read `How To Contribute `__. Reporting Security Issues ------------------------- diff --git a/conftest.py b/conftest.py index 805b90aff2c..c7651f46f89 100644 --- a/conftest.py +++ b/conftest.py @@ -193,6 +193,13 @@ def django_db_setup(django_db_setup, django_db_blocker, django_db_use_migrations type='text', required=False ) + ProductAttribute.objects.create( + product_class=coupon, + name='Salesforce Opportunity Line Item', + code='salesforce_opportunity_line_item', + type='text', + required=False + ) ProductAttribute.objects.create( product_class=coupon, name='Is Public Code?', diff --git a/docs/additional_features/gate_ecommerce.rst b/docs/additional_features/gate_ecommerce.rst index 63d55ec8d23..d0377bbcd83 100644 --- a/docs/additional_features/gate_ecommerce.rst +++ b/docs/additional_features/gate_ecommerce.rst @@ -63,6 +63,12 @@ Waffle offers the following feature gates. - Switch - Allow a missing LMS user id without raising a MissingLmsUserIdException. For background, see `0004-unique-identifier-for-users `_ + * - disable_redundant_payment_check_for_mobile + - Switch + - Enable returning an error for duplicate transaction_id for mobile in-app purchases. + * - mail_mobile_team_for_change_in_course + - Switch + - Alert mobile team for a change in a course having mobile seats, so that they can adjust prices on mobile platforms. * - enable_stripe_payment_processor - Flag - Ignore client side payment processor setting and use Stripe. For background, see `frontend-app-payment 0005-stripe-custom-actions `_. diff --git a/docs/conf.py b/docs/conf.py index b2e115fe6bc..27c41a249d0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -14,8 +14,9 @@ from __future__ import absolute_import import os +from datetime import datetime + -import edx_theme # on_rtd is whether we are on readthedocs.org on_rtd = os.environ.get('READTHEDOCS', None) == 'True' @@ -33,7 +34,6 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ - 'edx_theme', ] # Add any paths that contain templates here, relative to this directory. @@ -50,8 +50,8 @@ # General information about the project. project = u'E-Commerce Service' -author = edx_theme.AUTHOR -copyright = edx_theme.COPYRIGHT # pylint: disable=redefined-builtin +author = 'edX Inc.' +copyright = f'{datetime.now().year}, edX Inc.' # pylint: disable=redefined-builtin # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -101,15 +101,44 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'edx_theme' +html_theme = 'sphinx_book_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -# html_theme_options = {} - +html_theme_options = { + "repository_url": "https://github.com/openedx/ecommerce", + "repository_branch": "master", + "path_to_docs": "docs/", + "home_page_in_toc": True, + "use_repository_button": True, + "use_issues_button": True, + "use_edit_page_button": True, + # Please don't change unless you know what you're doing. + "extra_footer": """ + + Creative Commons License + +
+ These works by + The Center for Reimagining Learning + are licensed under a + Creative Commons Attribution-ShareAlike 4.0 International License. + """ +} # Add any paths that contain custom themes here, relative to this directory. -html_theme_path = [edx_theme.get_html_theme_path()] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". @@ -120,12 +149,12 @@ # The name of an image file (relative to this directory) to place at the top # of the sidebar. -# html_logo = None +html_logo = "https://logos.openedx.org/open-edx-logo-color.png" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -# html_favicon = None +html_favicon = "https://logos.openedx.org/open-edx-favicon.ico" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/ecommerce/bff/subscriptions/permissions.py b/ecommerce/bff/subscriptions/permissions.py new file mode 100644 index 00000000000..f99d26cd72c --- /dev/null +++ b/ecommerce/bff/subscriptions/permissions.py @@ -0,0 +1,15 @@ +""" +Permission classes for Product Entitlement Information API +""" +from django.conf import settings +from rest_framework import permissions + + +class CanGetProductEntitlementInfo(permissions.BasePermission): + """ + Grant access to the product entitlement API for the service user or superusers. + """ + + def has_permission(self, request, view): + return request.user.is_superuser or request.user.is_staff or ( + request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME) diff --git a/ecommerce/bff/subscriptions/serializers.py b/ecommerce/bff/subscriptions/serializers.py new file mode 100644 index 00000000000..0030d41eb8a --- /dev/null +++ b/ecommerce/bff/subscriptions/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class CourseEntitlementInfoSerializer(serializers.Serializer): + course_uuid = serializers.CharField() + mode = serializers.CharField() + sku = serializers.CharField() diff --git a/ecommerce/bff/subscriptions/tests/test_permissions.py b/ecommerce/bff/subscriptions/tests/test_permissions.py new file mode 100644 index 00000000000..2c3a70f2942 --- /dev/null +++ b/ecommerce/bff/subscriptions/tests/test_permissions.py @@ -0,0 +1,36 @@ +""" +Tests for subscriptions API permissions +""" + +from ecommerce.bff.subscriptions.permissions import CanGetProductEntitlementInfo +from ecommerce.tests.testcases import TestCase + + +class CanGetProductEntitlementInfoTest(TestCase): + """ Tests for get product entitlement API permissions """ + + def test_api_permission_staff(self): + self.user = self.create_user(is_staff=True) + self.request.user = self.user + result = CanGetProductEntitlementInfo().has_permission(self.request, None) + assert result is True + + def test_api_permission_user_granted_permission(self): + user = self.create_user() + self.request.user = user + + with self.settings(SUBSCRIPTIONS_SERVICE_WORKER_USERNAME=user.username): + result = CanGetProductEntitlementInfo().has_permission(self.request, None) + assert result is True + + def test_api_permission_superuser(self): + self.user = self.create_user(is_superuser=True) + self.request.user = self.user + result = CanGetProductEntitlementInfo().has_permission(self.request, None) + assert result is True + + def test_api_permission_user_not_granted_permission(self): + self.user = self.create_user() + self.request.user = self.user + result = CanGetProductEntitlementInfo().has_permission(self.request, None) + assert result is False diff --git a/ecommerce/bff/subscriptions/tests/test_subscription_views.py b/ecommerce/bff/subscriptions/tests/test_subscription_views.py new file mode 100644 index 00000000000..0907def341e --- /dev/null +++ b/ecommerce/bff/subscriptions/tests/test_subscription_views.py @@ -0,0 +1,143 @@ +import json +import uuid +from unittest import mock + +from django.urls import reverse +from oscar.core.loading import get_model +from oscar.test.factories import ProductFactory +from rest_framework import status + +from ecommerce.core.constants import COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME +from ecommerce.core.models import SiteConfiguration +from ecommerce.coupons.tests.mixins import DiscoveryMockMixin +from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin +from ecommerce.tests.factories import ProductFactory +from ecommerce.tests.testcases import TestCase + +Catalog = get_model('catalogue', 'Catalog') +StockRecord = get_model('partner', 'StockRecord') +Product = get_model('catalogue', 'Product') +ProductClass = get_model('catalogue', 'ProductClass') + + +class ProductEntitlementInfoViewTestCase(DiscoveryTestMixin, DiscoveryMockMixin, TestCase): + + def setUp(self): + super().setUp() + self.user = self.create_user(is_staff=True) + self.client.login(username=self.user.username, password=self.password) + self.ip_address = "mock_address" + + site_configuration = SiteConfiguration.objects.get(site=self.site) + site_configuration.enable_embargo_check = True + site_configuration.save() + + @mock.patch('ecommerce.bff.subscriptions.views.embargo_check') + def test_with_skus(self, mock_embargo_check): + mock_embargo_check.return_value = True + product_class, _ = ProductClass.objects.get_or_create(name=COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME) + + product1 = ProductFactory(title="test product 1", product_class=product_class, stockrecords__partner=self.partner) + product1.attr.UUID = str(uuid.uuid4()) + product1.attr.certificate_type = 'verified' + product1.attr.id_verification_required = False + + product2 = ProductFactory(title="test product 2", product_class=product_class, stockrecords__partner=self.partner) + product2.attr.UUID = str(uuid.uuid4()) + product2.attr.certificate_type = 'professional' + product2.attr.id_verification_required = True + + product1.attr.save() + product2.attr.save() + product1.refresh_from_db() + product2.refresh_from_db() + + url = reverse('bff:subscriptions:product-entitlement-info') + + response = self.client.post(url, data={'skus': [product1.stockrecords.first().partner_sku, + product2.stockrecords.first().partner_sku], + 'user_ip_address': self.ip_address, 'username': self.user.username + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = {'data': [ + {'course_uuid': product1.attr.UUID, 'mode': product1.attr.certificate_type, + 'sku': product1.stockrecords.first().partner_sku}, + {'course_uuid': product2.attr.UUID, 'mode': product2.attr.certificate_type, + 'sku': product2.stockrecords.first().partner_sku}, + ]} + self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data) + + @mock.patch('ecommerce.bff.subscriptions.views.logger.error') + def test_with_valid_and_invalid_products(self, mock_log): + product_class, _ = ProductClass.objects.get_or_create(name=COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME) + + product1 = ProductFactory(title="test product 1", product_class=product_class, stockrecords__partner=self.partner) + product1.attr.UUID = str(uuid.uuid4()) + product1.attr.certificate_type = 'verified' + product1.attr.id_verification_required = False + + # product2 is invalid because it does not have either one or both of UUID and certificate_type + product2 = ProductFactory(title="test product 2", product_class=product_class, stockrecords__partner=self.partner) + + product1.attr.save() + product1.refresh_from_db() + + url = reverse('bff:subscriptions:product-entitlement-info') + + response = self.client.post(url, data={'skus': [product1.stockrecords.first().partner_sku, + product2.stockrecords.first().partner_sku], + 'user_ip_address': self.ip_address, 'username': self.user.username + }) + + mock_log.assert_called_once_with(f"B2C_SUBSCRIPTIONS: Product {product2}" + f" does not have a UUID attribute or mode is None") + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = {'data': [ + {'course_uuid': product1.attr.UUID, 'mode': product1.attr.certificate_type, + 'sku': product1.stockrecords.first().partner_sku} + ]} + self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data) + + def test_with_invalid_sku(self): + url = reverse('bff:subscriptions:product-entitlement-info') + response = self.client.post(url, data={'skus': ["blah", "blah-2"], + 'user_ip_address': self.ip_address, 'username': self.user.username + }) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + expected_data = {'error': 'Products with SKU(s) [blah, blah-2] do not exist.'} + self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data) + + def test_with_empty_sku(self): + url = reverse('bff:subscriptions:product-entitlement-info') + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + expected_data = {'error': 'No SKUs provided.'} + self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data) + + @mock.patch('ecommerce.bff.subscriptions.views.embargo_check') + def test_embargo_failure(self, mock_embargo_check): + # In actual we don't expect Embargo to be False for any COURSE ENTITLEMENT product + # in its current Implementation. But we are mocking it to test the failure case. + # This will be fixed as a result of https://2u-internal.atlassian.net/browse/REV-3559 + + mock_embargo_check.return_value = False + product_class, _ = ProductClass.objects.get_or_create(name=COURSE_ENTITLEMENT_PRODUCT_CLASS_NAME) + + product1 = ProductFactory(title="test product 1", product_class=product_class, stockrecords__partner=self.partner) + product1.attr.UUID = str(uuid.uuid4()) + product1.attr.certificate_type = 'verified' + product1.attr.id_verification_required = False + + product1.attr.save() + product1.refresh_from_db() + + url = reverse('bff:subscriptions:product-entitlement-info') + + response = self.client.post(url, data={'skus': [product1.stockrecords.first().partner_sku], + 'user_ip_address': self.ip_address, 'username': self.user.username + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_data = {'error': 'User blocked by embargo check', 'error_code': 'embargo_failed'} + self.assertCountEqual(json.loads(response.content.decode('utf-8')), expected_data) diff --git a/ecommerce/bff/subscriptions/urls.py b/ecommerce/bff/subscriptions/urls.py new file mode 100644 index 00000000000..2480fb7b41e --- /dev/null +++ b/ecommerce/bff/subscriptions/urls.py @@ -0,0 +1,9 @@ + + +from django.urls import path + +from ecommerce.bff.subscriptions.views import ProductEntitlementInfoView + +urlpatterns = [ + path('product-entitlement-info/', ProductEntitlementInfoView.as_view(), name='product-entitlement-info'), +] diff --git a/ecommerce/bff/subscriptions/views.py b/ecommerce/bff/subscriptions/views.py new file mode 100644 index 00000000000..a4d087c767a --- /dev/null +++ b/ecommerce/bff/subscriptions/views.py @@ -0,0 +1,94 @@ +import logging + +from django.contrib.auth import get_user_model +from oscar.core.loading import get_model +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from ecommerce.bff.subscriptions.permissions import CanGetProductEntitlementInfo +from ecommerce.bff.subscriptions.serializers import CourseEntitlementInfoSerializer +from ecommerce.extensions.api.exceptions import BadRequestException +from ecommerce.extensions.api.throttles import ServiceUserThrottle +from ecommerce.extensions.partner.shortcuts import get_partner_for_site +from ecommerce.extensions.payment.utils import embargo_check + +logger = logging.getLogger(__name__) + +Product = get_model('catalogue', 'Product') +User = get_user_model() + + +class ProductEntitlementInfoView(generics.GenericAPIView): + + serializer_class = CourseEntitlementInfoSerializer + permission_classes = (IsAuthenticated, CanGetProductEntitlementInfo) + throttle_classes = [ServiceUserThrottle] + + def post(self, request, *args, **kwargs): + try: + skus = request.POST.getlist('skus', []) + username = request.POST.get('username', None) + site = request.site + user_ip_address = request.POST.get('user_ip_address', None) + + products = self._get_products_by_skus(skus) + available_products = self._get_available_products(products) + data = [] + if request.site.siteconfiguration.enable_embargo_check: + if not embargo_check(username, site, available_products, user_ip_address): + logger.error( + 'B2C_SUBSCRIPTIONS: User [%s] blocked by embargo, not continuing with the checkout process.', + username + ) + return Response({'error': 'User blocked by embargo check', + 'error_code': 'embargo_failed'}, + status=status.HTTP_200_OK) + + for product in available_products: + mode = self._mode_for_product(product) + if hasattr(product.attr, 'UUID') and mode is not None: + data.append({'course_uuid': product.attr.UUID, 'mode': mode, + 'sku': product.stockrecords.first().partner_sku}) + else: + logger.error(f"B2C_SUBSCRIPTIONS: Product {product}" + " does not have a UUID attribute or mode is None") + return Response({'data': data}) + except BadRequestException as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + def _get_available_products(self, products): + unavailable_product_ids = [] + for product in products: + purchase_info = self.request.strategy.fetch_for_product(product) + if not purchase_info.availability.is_available_to_buy: + logger.warning('B2C_SUBSCRIPTIONS: Product [%s] is not available to buy.', product.title) + unavailable_product_ids.append(product.id) + + available_products = products.exclude(id__in=unavailable_product_ids) + if not available_products: + raise BadRequestException('No product is available to buy.') + return available_products + + def _get_products_by_skus(self, skus): + if not skus: + raise BadRequestException(('No SKUs provided.')) + + partner = get_partner_for_site(self.request) + products = Product.objects.filter(stockrecords__partner=partner, stockrecords__partner_sku__in=skus) + if not products: + raise BadRequestException(('Products with SKU(s) [{skus}] do not exist.').format(skus=', '.join(skus))) + return products + + def _mode_for_product(self, product): + """ + Returns the purchaseable enrollment mode (aka course mode) for the specified product. + If a purchaseable enrollment mode cannot be determined, None is returned. + + """ + mode = getattr(product.attr, 'certificate_type', getattr(product.attr, 'seat_type', None)) + if not mode: + return None + if mode == 'professional' and not getattr(product.attr, 'id_verification_required', False): + return 'no-id-professional' + return mode diff --git a/ecommerce/bff/urls.py b/ecommerce/bff/urls.py index 350321c4dc1..61331ed2ff1 100644 --- a/ecommerce/bff/urls.py +++ b/ecommerce/bff/urls.py @@ -4,4 +4,6 @@ urlpatterns = [ url(r'^payment/', include(('ecommerce.bff.payment.urls', 'payment'))), + url(r'subscriptions/', include(('ecommerce.bff.subscriptions.urls', 'subscriptions'))) + ] diff --git a/ecommerce/core/management/commands/tests/test_unenroll_refunded_android_users.py b/ecommerce/core/management/commands/tests/test_unenroll_refunded_android_users.py new file mode 100644 index 00000000000..4c0af8550df --- /dev/null +++ b/ecommerce/core/management/commands/tests/test_unenroll_refunded_android_users.py @@ -0,0 +1,52 @@ +""" +Tests for Django management command to un-enroll refunded android users. +""" +from django.core.management import call_command +from mock import patch +from testfixtures import LogCapture + +from ecommerce.tests.testcases import TestCase + + +class TestUnenrollRefundedAndroidUsersCommand(TestCase): + + LOGGER_NAME = 'ecommerce.core.management.commands.unenroll_refunded_android_users' + + @patch('requests.get') + def test_handle_pass(self, mock_response): + """ Test using mock response from setup, using threshold it will clear""" + + mock_response.return_value.status_code = 200 + + with LogCapture(self.LOGGER_NAME) as log: + call_command('unenroll_refunded_android_users') + + log.check( + ( + self.LOGGER_NAME, + 'INFO', + 'Sending request to un-enroll refunded android users' + ) + ) + + @patch('requests.get') + def test_handle_fail(self, mock_response): + """ Test using mock response from setup, using threshold it will clear""" + + mock_response.return_value.status_code = 400 + + with LogCapture(self.LOGGER_NAME) as log: + call_command('unenroll_refunded_android_users') + + log.check( + ( + self.LOGGER_NAME, + 'INFO', + 'Sending request to un-enroll refunded android users' + ), + ( + self.LOGGER_NAME, + 'ERROR', + 'Failed to refund android users with status code 400' + ) + ) diff --git a/ecommerce/core/management/commands/unenroll_refunded_android_users.py b/ecommerce/core/management/commands/unenroll_refunded_android_users.py new file mode 100644 index 00000000000..2a9c34f4188 --- /dev/null +++ b/ecommerce/core/management/commands/unenroll_refunded_android_users.py @@ -0,0 +1,27 @@ +""" +Django management command to un-enroll refunded android users. + +Command is run by Jenkins job daily. +""" +import logging + +import requests +from django.core.management.base import BaseCommand +from rest_framework import status + +from ecommerce.core.models import SiteConfiguration + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Management command to un-enroll refunded android users.' + + def handle(self, *args, **options): + site = SiteConfiguration.objects.first() + refund_api_url = '{}/api/iap/v1/android/refund/'.format(site.build_ecommerce_url()) + logger.info("Sending request to un-enroll refunded android users") + response = requests.get(refund_api_url) + + if response.status_code != status.HTTP_200_OK: + logger.error("Failed to refund android users with status code %s", response.status_code) diff --git a/ecommerce/coupons/tests/mixins.py b/ecommerce/coupons/tests/mixins.py index 0ba81020640..bb037f1d6df 100644 --- a/ecommerce/coupons/tests/mixins.py +++ b/ecommerce/coupons/tests/mixins.py @@ -472,13 +472,21 @@ def coupon_product_class(self): type='text' ) + factories.ProductAttributeFactory( + product_class=pc, + name='Salesforce Opportunity Line Item', + code='salesforce_opportunity_line_item', + type='text' + ) + return pc def create_coupon(self, benefit_type=Benefit.PERCENTAGE, benefit_value=100, catalog=None, catalog_query=None, client=None, code='', course_seat_types=None, email_domains=None, enterprise_customer=None, enterprise_customer_catalog=None, max_uses=None, note=None, partner=None, price=100, quantity=5, title='Test coupon', voucher_type=Voucher.SINGLE_USE, course_catalog=None, program_uuid=None, - start_datetime=None, end_datetime=None, sales_force_id=None): + start_datetime=None, end_datetime=None, sales_force_id=None, + salesforce_opportunity_line_item=None): """Helper method for creating a coupon. Arguments: @@ -502,6 +510,7 @@ def create_coupon(self, benefit_type=Benefit.PERCENTAGE, benefit_value=100, cata voucher_type (str): Voucher type program_uuid (str): Program UUID sales_force_id (str): Sales Force Opprtunity ID + salesforce_opportunity_line_item (str): Sales Force Opportunity Line Item ID Returns: coupon (Coupon) @@ -544,6 +553,7 @@ def create_coupon(self, benefit_type=Benefit.PERCENTAGE, benefit_value=100, cata program_uuid=program_uuid, site=self.site, sales_force_id=sales_force_id, + salesforce_opportunity_line_item=salesforce_opportunity_line_item, ) request = RequestFactory() diff --git a/ecommerce/courses/models.py b/ecommerce/courses/models.py index b6277ec374f..62c10ed6c80 100644 --- a/ecommerce/courses/models.py +++ b/ecommerce/courses/models.py @@ -345,10 +345,10 @@ def toggle_enrollment_code_status(self, is_active): enrollment_code = self.get_enrollment_code() if enrollment_code: if is_active: - seat = self.seat_products.get( + seat = self.seat_products.filter( attributes__name='certificate_type', attribute_values__value_text=enrollment_code.attr.seat_type - ) + ).order_by('-expires').first() enrollment_code.expires = seat.expires if seat.expires else now() + timedelta(days=365) else: enrollment_code.expires = now() - timedelta(days=365) diff --git a/ecommerce/courses/tests/test_models.py b/ecommerce/courses/tests/test_models.py index 51c9bc36073..a875b230f21 100644 --- a/ecommerce/courses/tests/test_models.py +++ b/ecommerce/courses/tests/test_models.py @@ -42,8 +42,10 @@ def test_seat_products(self): # Create the seat products seats = [course.create_or_update_seat('honor', False, 0), - course.create_or_update_seat('verified', True, 50, create_enrollment_code=True)] - self.assertEqual(course.products.count(), 4) + course.create_or_update_seat('verified', True, 50, create_enrollment_code=True), + course.create_or_update_seat('verified', True, 60), + course.create_or_update_seat('verified', True, 70)] + self.assertEqual(course.products.count(), 6) # The property should return only the child seats. self.assertEqual(set(course.seat_products), set(seats)) @@ -371,3 +373,18 @@ def test_deactivate_enrollment_code(self): ec_expires = now() - timedelta(days=365) self.assertEqual(course.get_enrollment_code().expires, ec_expires) self.assertIsNone(course.enrollment_code_product) + + def test_toggle_enrollment_code_with_multiple_seats(self): + """Verify enrollment code expiration date is set when course has multiple seats""" + seat_one_expires = now() + timedelta(days=365) + seat_two_expires = now() + timedelta(days=366) + seat_three_expires = now() + timedelta(days=367) + course, _, enrollment_code = self.create_course_seat_and_enrollment_code(expires=seat_one_expires) + course.create_or_update_seat("honor", False, 0) + course.create_or_update_seat("verified", True, 10, expires=seat_two_expires) + course.create_or_update_seat("verified", True, 10, expires=seat_three_expires) + course.toggle_enrollment_code_status(True) + ec_expires = seat_three_expires + + self.assertEqual(course.get_enrollment_code().expires, ec_expires) + self.assertEqual(course.enrollment_code_product, enrollment_code) diff --git a/ecommerce/enterprise/constants.py b/ecommerce/enterprise/constants.py index a70b5b632fe..60f58d33fff 100644 --- a/ecommerce/enterprise/constants.py +++ b/ecommerce/enterprise/constants.py @@ -17,3 +17,7 @@ # Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006 OR be "none" (to accommodate # potential edge case). ENTERPRISE_SALES_FORCE_ID_REGEX = r'^006[a-zA-Z0-9]{15}$|^none$' + +# Salesforce Opportunity Line item must be 18 alphanumeric characters +# and begin with a number OR be "none" (to accommodate potential edge case). +ENTERPRISE_SALESFORCE_OPPORTUNITY_LINE_ITEM_REGEX = r'^00k[a-zA-Z0-9]{15}$|^none$' diff --git a/ecommerce/enterprise/forms.py b/ecommerce/enterprise/forms.py index 3e6f31bc901..47313cc2af7 100644 --- a/ecommerce/enterprise/forms.py +++ b/ecommerce/enterprise/forms.py @@ -15,7 +15,10 @@ from ecommerce.enterprise.benefits import BENEFIT_MAP, BENEFIT_TYPE_CHOICES from ecommerce.enterprise.conditions import EnterpriseCustomerCondition -from ecommerce.enterprise.constants import ENTERPRISE_SALES_FORCE_ID_REGEX +from ecommerce.enterprise.constants import ( + ENTERPRISE_SALES_FORCE_ID_REGEX, + ENTERPRISE_SALESFORCE_OPPORTUNITY_LINE_ITEM_REGEX +) from ecommerce.enterprise.utils import convert_comma_separated_string_to_list, get_enterprise_customer from ecommerce.extensions.fulfillment.status import ORDER from ecommerce.extensions.offer.models import OFFER_PRIORITY_ENTERPRISE @@ -47,7 +50,9 @@ class EnterpriseOfferForm(forms.ModelForm): prepaid_invoice_amount = forms.DecimalField( required=False, decimal_places=5, max_digits=15, min_value=0, label=_('Prepaid Invoice Amount') ) - sales_force_id = forms.CharField(max_length=30, required=True, label=_('Salesforce Opportunity ID')) + sales_force_id = forms.CharField(max_length=30, required=False, label=_('Salesforce Opportunity ID')) + salesforce_opportunity_line_item = forms.CharField( + max_length=30, required=False, label=_('Salesforce Opportunity Line Item')) emails_for_usage_alert = forms.CharField( required=False, label=_("Emails Addresses"), @@ -65,6 +70,7 @@ class Meta: 'enterprise_customer_uuid', 'enterprise_customer_catalog_uuid', 'start_datetime', 'end_datetime', 'benefit_type', 'benefit_value', 'contract_discount_type', 'contract_discount_value', 'prepaid_invoice_amount', 'sales_force_id', + 'salesforce_opportunity_line_item', 'max_global_applications', 'max_discount', 'max_user_applications', 'max_user_discount', 'emails_for_usage_alert', 'usage_email_frequency' ] @@ -154,13 +160,23 @@ def clean_max_global_applications(self): def clean_sales_force_id(self): # validate sales_force_id format sales_force_id = self.cleaned_data.get('sales_force_id') - if not re.match(ENTERPRISE_SALES_FORCE_ID_REGEX, sales_force_id): + if sales_force_id and not re.match(ENTERPRISE_SALES_FORCE_ID_REGEX, sales_force_id): self.add_error( 'sales_force_id', _('Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006.') ) return self.cleaned_data.get('sales_force_id') + def clean_salesforce_opportunity_line_item(self): + # validate salesforce_opportunity_line_item format + salesforce_opportunity_line_item = self.cleaned_data.get('salesforce_opportunity_line_item') + if not re.match(ENTERPRISE_SALESFORCE_OPPORTUNITY_LINE_ITEM_REGEX, salesforce_opportunity_line_item): + self.add_error( + 'salesforce_opportunity_line_item', + _('The Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.') + ) + return self.cleaned_data.get('salesforce_opportunity_line_item') + def clean_max_discount(self): max_discount = self.cleaned_data.get('max_discount') # validate against non-decimal and negative values @@ -303,6 +319,7 @@ def save(self, commit=True): enterprise_customer_uuid = self.cleaned_data['enterprise_customer_uuid'] enterprise_customer_catalog_uuid = self.cleaned_data['enterprise_customer_catalog_uuid'] sales_force_id = self.cleaned_data['sales_force_id'] + salesforce_opportunity_line_item = self.cleaned_data['salesforce_opportunity_line_item'] site = self.request.site contract_discount_value = self.cleaned_data['contract_discount_value'] @@ -330,6 +347,7 @@ def save(self, commit=True): self.instance.partner = site.siteconfiguration.partner self.instance.priority = OFFER_PRIORITY_ENTERPRISE self.instance.sales_force_id = sales_force_id + self.instance.salesforce_opportunity_line_item = salesforce_opportunity_line_item self.instance.max_global_applications = self.cleaned_data.get('max_global_applications') self.instance.max_discount = self.cleaned_data.get('max_discount') diff --git a/ecommerce/enterprise/management/commands/seed_enterprise_devstack_data.py b/ecommerce/enterprise/management/commands/seed_enterprise_devstack_data.py index aa7a5122eb4..ee12f0a0f55 100644 --- a/ecommerce/enterprise/management/commands/seed_enterprise_devstack_data.py +++ b/ecommerce/enterprise/management/commands/seed_enterprise_devstack_data.py @@ -127,6 +127,7 @@ def create_coupon(self, api_client, ecommerce_api_url, enterprise_catalog_api_ur "end_datetime": str(now() + datetime.timedelta(days=10)), "benefit_value": 100, "sales_force_id": '006aaaaaaaaaaaaaaa', + "sales_force_opportunity_line_item": '00kaaaaaaaaaaaaaaa', } url = urljoin(f"{ecommerce_api_url}/", "enterprise/coupons/") response = api_client.post(url, json=request_obj) diff --git a/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py b/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py index 3a056b4e363..1b183933b0d 100644 --- a/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py +++ b/ecommerce/enterprise/management/commands/send_api_triggered_offer_emails.py @@ -225,6 +225,7 @@ def get_email_content(self, site, offer): 'current_usage_str': current_usage if is_enrollment_limit_offer else money_template.format(current_usage), 'remaining_balance': remaining_balance, 'remaining_balance_str': remaining_balance_str, + 'enterprise_customer_name': offer.condition.enterprise_customer_name, } @staticmethod diff --git a/ecommerce/enterprise/tests/mixins.py b/ecommerce/enterprise/tests/mixins.py index 2473240bf51..f91b943b9c7 100644 --- a/ecommerce/enterprise/tests/mixins.py +++ b/ecommerce/enterprise/tests/mixins.py @@ -464,6 +464,15 @@ def mock_consent_get(self, username, course_id, ec_uuid): ec_uuid ) + def mock_consent_post(self, username, course_id, ec_uuid): + self.mock_consent_response( + username, + course_id, + ec_uuid, + method=responses.POST, + granted=True + ) + def mock_consent_missing(self, username, course_id, ec_uuid): self.mock_consent_response( username, diff --git a/ecommerce/enterprise/tests/test_forms.py b/ecommerce/enterprise/tests/test_forms.py index 734a33d7d48..74b27a9c556 100644 --- a/ecommerce/enterprise/tests/test_forms.py +++ b/ecommerce/enterprise/tests/test_forms.py @@ -46,6 +46,7 @@ def generate_data(self, **kwargs): 'contract_discount_value': self.contract_discount_value, 'prepaid_invoice_amount': self.prepaid_invoice_amount, 'sales_force_id': '006abcde0123456789', + 'salesforce_opportunity_line_item': '00kabcde9876543210', 'max_global_applications': 2, 'max_discount': 300, 'max_user_discount': 50, @@ -59,13 +60,14 @@ def assert_enterprise_offer_conditions(self, offer, enterprise_customer_uuid, en enterprise_customer_catalog_uuid, expected_benefit_value, expected_benefit_type, expected_name, expected_contract_discount_type, expected_contract_discount_value, expected_prepaid_invoice_amount, - expected_sales_force_id, expected_max_global_applications, - expected_max_discount, expected_max_user_applications, - expected_max_user_discount): + expected_sales_force_id, expected_salesforce_opportunity_line_item, + expected_max_global_applications, expected_max_discount, + expected_max_user_applications, expected_max_user_discount): """ Assert the given offer's parameters match the expected values. """ self.assertEqual(str(offer.name), expected_name) self.assertEqual(offer.offer_type, ConditionalOffer.SITE) self.assertEqual(offer.sales_force_id, expected_sales_force_id) + self.assertEqual(offer.salesforce_opportunity_line_item, expected_salesforce_opportunity_line_item) self.assertEqual(offer.status, ConditionalOffer.OPEN) self.assertEqual(offer.max_basket_applications, 1) self.assertEqual(offer.partner, self.partner) @@ -256,6 +258,7 @@ def test_save_create(self): data['contract_discount_value'], data['prepaid_invoice_amount'], data['sales_force_id'], + data['salesforce_opportunity_line_item'], data['max_global_applications'], data['max_discount'], data['max_user_applications'], @@ -288,6 +291,7 @@ def test_save_create_special_char_title(self): data['contract_discount_value'], data['prepaid_invoice_amount'], data['sales_force_id'], + data['salesforce_opportunity_line_item'], data['max_global_applications'], data['max_discount'], data['max_user_applications'], @@ -330,6 +334,7 @@ def test_save_edit(self): data['contract_discount_value'], data['prepaid_invoice_amount'], data['sales_force_id'], + data['salesforce_opportunity_line_item'], data['max_global_applications'], data['max_discount'], data['max_user_applications'], @@ -372,6 +377,7 @@ def test_save_offer_name(self): data['contract_discount_value'], data['prepaid_invoice_amount'], data['sales_force_id'], + data['salesforce_opportunity_line_item'], data['max_global_applications'], data['max_discount'], data['max_user_applications'], @@ -460,7 +466,6 @@ def _test_sales_force_id(self, sales_force_id, expected_error, is_update_view): ('006ABCDE0123456789', None), ('none', None), # Invalid Cases - (None, 'This field is required.'), ('006ABCDE012345678123143', 'Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006.'), ('006ABCDE01234', 'Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006.'), ('007ABCDE0123456789', 'Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006.'), @@ -474,6 +479,46 @@ def test_sales_force_id(self, sales_force_id, expected_error): self._test_sales_force_id(sales_force_id, expected_error, is_update_view=False) self._test_sales_force_id(sales_force_id, expected_error, is_update_view=True) + def _test_salesforce_opportunity_line_item(self, salesforce_opportunity_line_item, expected_error, is_update_view): + """ + Verify that `clean` for `salesforce_opportunity_line_item` field is working as expected. + """ + instance = None + if is_update_view: + instance = factories.EnterpriseOfferFactory() + data = self.generate_data(salesforce_opportunity_line_item=salesforce_opportunity_line_item) + if expected_error: + expected_errors = {'salesforce_opportunity_line_item': [expected_error]} + self.assert_form_errors(data, expected_errors, instance) + else: + form = EnterpriseOfferForm(data=data, instance=instance) + self.assertTrue(form.is_valid()) + + @ddt.data( + # Valid Cases + ('00kabcde0123456789', None), + ('00kABCDE0123456789', None), + ('none', None), + # Invalid Cases + ('006ABCDE012345678123143', + 'The Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ('006ABCDE01234', + 'The Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ('a07ABCDE0123456789', + 'The Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ('006ABCDE0 12345678', + 'The Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ) + @ddt.unpack + def test_salesforce_opportunity_line_item(self, salesforce_opportunity_line_item, expected_error): + """ + Verify that `clean` for `salesforce_opportunity_line_item` field is working as expected. + """ + self._test_salesforce_opportunity_line_item( + salesforce_opportunity_line_item, expected_error, is_update_view=False) + self._test_salesforce_opportunity_line_item( + salesforce_opportunity_line_item, expected_error, is_update_view=True) + def test_max_discount_clean_with_negative_value(self): """ Verify that `clean` for `max_discount` field raises correct error for negative values. diff --git a/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py b/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py index 8e2885bd757..431dc507992 100644 --- a/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py +++ b/ecommerce/enterprise/tests/test_send_enterprise_offer_limit_emails.py @@ -148,6 +148,7 @@ def test_low_balance_email(self): 'offer_type': 'Booking', 'offer_name': offer_with_low_balance.name, 'current_usage': 7500.0, 'current_usage_str': '$7,500.00', 'remaining_balance': 2500.0, 'remaining_balance_str': '$2,500.00', + 'enterprise_customer_name': offer_with_low_balance.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.LOW_BALANCE] ), @@ -160,6 +161,8 @@ def test_low_balance_email(self): 'offer_type': 'Booking', 'offer_name': offer_with_low_balance_email_sent_before.name, 'current_usage': 7500.0, 'current_usage_str': '$7,500.00', 'remaining_balance': 2500.0, 'remaining_balance_str': '$2,500.00', + 'enterprise_customer_name': + offer_with_low_balance_email_sent_before.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), @@ -173,6 +176,8 @@ def test_low_balance_email(self): 'offer_name': replenished_offer_with_low_balance_email_sent_before.name, 'current_usage': 15000.0, 'current_usage_str': '$15,000.00', 'remaining_balance': 5000.0, 'remaining_balance_str': '$5,000.00', + 'enterprise_customer_name': + replenished_offer_with_low_balance_email_sent_before.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.LOW_BALANCE] ), @@ -254,6 +259,7 @@ def test_no_balance_email(self): 'offer_type': 'Booking', 'offer_name': offer_with_no_balance.name, 'current_usage': 9900.0, 'current_usage_str': '$9,900.00', 'remaining_balance': 100.0, 'remaining_balance_str': '$100.00', + 'enterprise_customer_name': offer_with_no_balance.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.OUT_OF_BALANCE] ), @@ -266,6 +272,8 @@ def test_no_balance_email(self): 'offer_type': 'Booking', 'offer_name': replenished_offer_with_no_balance_email_sent_before.name, 'current_usage': 19900.0, 'current_usage_str': '$19,900.00', 'remaining_balance': 100.0, 'remaining_balance_str': '$100.00', + 'enterprise_customer_name': + replenished_offer_with_no_balance_email_sent_before.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.OUT_OF_BALANCE] ), @@ -343,6 +351,7 @@ def test_digest_email(self): 'total_limit': 10000.0, 'offer_name': offer_1.name, 'current_usage': 5000.0, 'current_usage_str': '$5,000.00', 'remaining_balance': 5000.0, 'remaining_balance_str': '$5,000.00', + 'enterprise_customer_name': offer_1.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), @@ -355,6 +364,7 @@ def test_digest_email(self): 'offer_type': 'Booking', 'offer_name': offer_2.name, 'current_usage': 5000.0, 'current_usage_str': '$5,000.00', 'remaining_balance': 5000.0, 'remaining_balance_str': '$5,000.00', + 'enterprise_customer_name': offer_2.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), @@ -367,6 +377,7 @@ def test_digest_email(self): 'offer_type': 'Enrollment', 'offer_name': offer_with_daily_frequency.name, 'current_usage': 0, 'current_usage_str': 0, 'remaining_balance': 10, 'remaining_balance_str': '10', + 'enterprise_customer_name': offer_with_daily_frequency.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), @@ -379,6 +390,7 @@ def test_digest_email(self): 'offer_type': 'Enrollment', 'offer_name': offer_with_weekly_frequency.name, 'current_usage': 0, 'current_usage_str': 0, 'remaining_balance': 10, 'remaining_balance_str': '10', + 'enterprise_customer_name': offer_with_weekly_frequency.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), @@ -391,6 +403,7 @@ def test_digest_email(self): 'offer_name': offer_with_monthly_frequency.name, 'current_usage': 0, 'current_usage_str': 0, 'remaining_balance': 10, 'remaining_balance_str': '10', + 'enterprise_customer_name': offer_with_monthly_frequency.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), @@ -434,6 +447,7 @@ def test_command_force_digest_single_enterprise(self): 'offer_type': 'Booking', 'offer_name': offer_1.name, 'current_usage': 5000.0, 'current_usage_str': '$5,000.00', 'remaining_balance': 5000.0, 'remaining_balance_str': '$5,000.00', + 'enterprise_customer_name': offer_1.condition.enterprise_customer_name, }, campaign_id=settings.CAMPAIGN_IDS_BY_EMAIL_TYPE[OfferUsageEmailTypes.DIGEST] ), diff --git a/ecommerce/enterprise/tests/test_utils.py b/ecommerce/enterprise/tests/test_utils.py index 73d89a4336e..1e10d88b313 100644 --- a/ecommerce/enterprise/tests/test_utils.py +++ b/ecommerce/enterprise/tests/test_utils.py @@ -18,6 +18,7 @@ from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin from ecommerce.enterprise.utils import ( CUSTOMER_CATALOGS_DEFAULT_RESPONSE, + create_enterprise_customer_user_consent, enterprise_customer_user_needs_consent, find_active_enterprise_customer_user, get_enterprise_catalog, @@ -144,6 +145,23 @@ def test_ecu_needs_consent(self): self.mock_consent_not_required(**opts) self.assertEqual(enterprise_customer_user_needs_consent(**kw), False) + @responses.activate + def test_ecu_create_consent(self): + opts = { + 'ec_uuid': 'fake-uuid', + 'course_id': 'course-v1:real+course+id', + 'username': 'johnsmith', + } + kw = { + 'enterprise_customer_uuid': 'fake-uuid', + 'course_id': 'course-v1:real+course+id', + 'username': 'johnsmith', + 'site': self.site + } + self.mock_access_token_response() + self.mock_consent_post(**opts) + self.assertEqual(create_enterprise_customer_user_consent(**kw), True) + def test_get_enterprise_customer_uuid(self): """ Verify that enterprise customer UUID is returned for a voucher with an associated enterprise customer. diff --git a/ecommerce/enterprise/tests/test_views.py b/ecommerce/enterprise/tests/test_views.py index af761ced600..ba228da4506 100644 --- a/ecommerce/enterprise/tests/test_views.py +++ b/ecommerce/enterprise/tests/test_views.py @@ -109,6 +109,7 @@ def test_post(self): 'contract_discount_value': 200, 'prepaid_invoice_amount': 2000, 'sales_force_id': '006abcde0123456789', + 'salesforce_opportunity_line_item': '00kabcde9876543210', 'usage_email_frequency': ConditionalOffer.DAILY } response = self.client.post(self.path, data, follow=False) @@ -130,6 +131,7 @@ def test_post(self): expected_discount_type = 'Absolute' expected_prepaid_invoice_amount = 12345 sales_force_id = '006abcde0123456789' + salesforce_opportunity_line_item = '00kabcde9876543210' data = { 'enterprise_customer_uuid': expected_ec_uuid, 'enterprise_customer_catalog_uuid': expected_ec_catalog_uuid, @@ -139,6 +141,7 @@ def test_post(self): 'contract_discount_type': expected_discount_type, 'prepaid_invoice_amount': expected_prepaid_invoice_amount, 'sales_force_id': sales_force_id, + 'salesforce_opportunity_line_item': salesforce_opportunity_line_item, 'usage_email_frequency': ConditionalOffer.DAILY } @@ -151,6 +154,7 @@ def test_post(self): self.assertIsNone(enterprise_offer.start_datetime) self.assertIsNone(enterprise_offer.end_datetime) self.assertEqual(enterprise_offer.sales_force_id, sales_force_id) + self.assertEqual(enterprise_offer.salesforce_opportunity_line_item, salesforce_opportunity_line_item) self.assertEqual(enterprise_offer.condition.enterprise_customer_uuid, expected_ec_uuid) self.assertEqual(enterprise_offer.condition.enterprise_customer_catalog_uuid, expected_ec_catalog_uuid) self.assertEqual(enterprise_offer.benefit.type, '') diff --git a/ecommerce/enterprise/utils.py b/ecommerce/enterprise/utils.py index 7e3aec5993e..192789e8fe0 100644 --- a/ecommerce/enterprise/utils.py +++ b/ecommerce/enterprise/utils.py @@ -355,6 +355,34 @@ def enterprise_customer_user_needs_consent(site, enterprise_customer_uuid, cours return response.json()['consent_required'] +def create_enterprise_customer_user_consent(site, enterprise_customer_uuid, course_id, username): + """ + Create a new consent for a particular username/EC UUID/course ID combination if one doesn't already exist. + + Args: + site (Site): The site which is handling the consent-sensitive request + enterprise_customer_uuid (str): The UUID of the relevant EnterpriseCustomer + course_id (str): The ID of the relevant course for enrollment + username (str): The username of the user attempting to enroll into the course + + Returns: + bool: consent recorded for the user specified by the username argument + for the EnterpriseCustomer specified by the enterprise_customer_uuid + argument and the course specified by the course_id argument. + """ + data = { + "username": username, + "enterprise_customer_uuid": enterprise_customer_uuid, + "course_id": course_id + } + api_client = site.siteconfiguration.oauth_api_client + consent_url = urljoin(f"{site.siteconfiguration.consent_api_url}/", "data_sharing_consent") + + response = api_client.post(consent_url, json=data) + response.raise_for_status() + return response.json()['consent_provided'] + + def get_enterprise_customer_uuid_from_voucher(voucher): """ Given a Voucher, find the associated Enterprise Customer UUID, if it exists. diff --git a/ecommerce/extensions/api/constatnts.py b/ecommerce/extensions/api/constatnts.py new file mode 100644 index 00000000000..1e4a5deebad --- /dev/null +++ b/ecommerce/extensions/api/constatnts.py @@ -0,0 +1,9 @@ +# .. toggle_name: mail_mobile_team_for_change_in_course +# .. toggle_type: waffle_switch +# .. toggle_default: False +# .. toggle_description: Alert mobile team for a change in a course having mobile seats. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2023-07-25 +# .. toggle_tickets: LEARNER-9377 +# .. toggle_status: supported +MAIL_MOBILE_TEAM_FOR_CHANGE_IN_COURSE = 'mail_mobile_team_for_change_in_course' diff --git a/ecommerce/extensions/api/handlers.py b/ecommerce/extensions/api/handlers.py deleted file mode 100644 index 0970e58acb5..00000000000 --- a/ecommerce/extensions/api/handlers.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Handler overrides for JWT authentication. - -See ARCH-276 for details of removing additional issuers and retiring this -custom jwt_decode_handler. - -""" - - -import logging - -import jwt -import waffle -from django.conf import settings -from edx_django_utils import monitoring as monitoring_utils -from edx_rest_framework_extensions.auth.jwt.decoder import jwt_decode_handler as edx_drf_extensions_jwt_decode_handler -from rest_framework_jwt.settings import api_settings - -logger = logging.getLogger(__name__) - -JWT_DECODE_HANDLER_METRIC_KEY = 'ecom_jwt_decode_handler' - - -def _ecommerce_jwt_decode_handler_multiple_issuers(token): - """ - Unlike the edx-drf-extensions jwt_decode_handler implementation, this - jwt_decode_handler loops over multiple issuers using the same config - format as the edx-drf-extensions decoder. Example:: - - JWT_AUTH: - JWT_ISSUERS: - - AUDIENCE: '{{ COMMON_JWT_AUDIENCE }}' - ISSUER: '{{ COMMON_JWT_ISSUER }}' - SECRET_KEY: '{{ COMMON_JWT_SECRET_KEY }}' - - See ARCH-276 for details of removing additional issuers and retiring this - custom jwt_decode_handler. - - """ - options = { - 'verify_exp': api_settings.JWT_VERIFY_EXPIRATION, - 'verify_aud': settings.JWT_AUTH['JWT_VERIFY_AUDIENCE'], - } - error_msg = '' - - # JWT_ISSUERS is not one of DRF-JWT's default settings, and cannot be accessed - # using the `api_settings` object without overriding DRF-JWT's defaults. - issuers = settings.JWT_AUTH['JWT_ISSUERS'] - - for issuer in issuers: - try: - return jwt.decode( - token, - issuer['SECRET_KEY'], - api_settings.JWT_VERIFY, - options=options, - leeway=api_settings.JWT_LEEWAY, - audience=issuer['AUDIENCE'], - issuer=issuer['ISSUER'], - algorithms=[api_settings.JWT_ALGORITHM] - ) - except jwt.InvalidIssuerError: - # Ignore these errors since we have multiple issuers - error_msg += "Issuer {} does not match token. ".format(issuer['ISSUER']) - except jwt.DecodeError: - # Ignore these errors since we have multiple issuers - error_msg += "Wrong secret_key for issuer {}. ".format(issuer['ISSUER']) - except jwt.InvalidAlgorithmError: # pragma: no cover - # These should all fail because asymmetric keys are not supported - error_msg += "Algorithm not supported. " - break - except jwt.InvalidTokenError: - error_msg += "Invalid token found using issuer {}. ".format(issuer['ISSUER']) - logger.exception('Custom config JWT decode failed!') - - raise jwt.InvalidTokenError( - 'All combinations of JWT issuers with updated config failed to validate the token. ' + error_msg - ) - - -def jwt_decode_handler(token): - """ - Attempt to decode the given token with each of the configured JWT issuers. - - Args: - token (str): The JWT to decode. - - Returns: - dict: The JWT's payload. - - Raises: - InvalidTokenError: If the token is invalid, or if none of the - configured issuer/secret-key combos can properly decode the token. - - """ - - # First, try ecommerce decoder that handles multiple issuers. - # See ARCH-276 for details of removing additional issuers and retiring this - # custom jwt_decode_handler. - try: - jwt_payload = _ecommerce_jwt_decode_handler_multiple_issuers(token) - monitoring_utils.set_custom_metric(JWT_DECODE_HANDLER_METRIC_KEY, 'ecommerce-multiple-issuers') - return jwt_payload - except Exception: # pylint: disable=broad-except - if waffle.switch_is_active('jwt_decode_handler.log_exception.ecommerce-multiple-issuers'): - logger.info('Failed to use ecommerce multiple issuer jwt_decode_handler.', exc_info=True) - - # Next, try jwt_decode_handler from edx_drf_extensions - # Note: this jwt_decode_handler can handle asymmetric keys, but only a - # single issuer. Therefore, the LMS must be the first configured issuer. - try: - jwt_payload = edx_drf_extensions_jwt_decode_handler(token) - monitoring_utils.set_custom_metric(JWT_DECODE_HANDLER_METRIC_KEY, 'edx-drf-extensions') - return jwt_payload - except Exception: # pylint: disable=broad-except - # continue and try again - if waffle.switch_is_active('jwt_decode_handler.log_exception.edx-drf-extensions'): - logger.info('Failed to use edx-drf-extensions jwt_decode_handler.', exc_info=True) - raise diff --git a/ecommerce/extensions/api/serializers.py b/ecommerce/extensions/api/serializers.py index d45dd8d503f..11825a2c891 100644 --- a/ecommerce/extensions/api/serializers.py +++ b/ecommerce/extensions/api/serializers.py @@ -34,7 +34,10 @@ from ecommerce.courses.models import Course from ecommerce.enterprise.benefits import BENEFIT_MAP as ENTERPRISE_BENEFIT_MAP from ecommerce.enterprise.conditions import sum_user_discounts_for_offer -from ecommerce.enterprise.constants import ENTERPRISE_SALES_FORCE_ID_REGEX +from ecommerce.enterprise.constants import ( + ENTERPRISE_SALES_FORCE_ID_REGEX, + ENTERPRISE_SALESFORCE_OPPORTUNITY_LINE_ITEM_REGEX +) from ecommerce.enterprise.utils import ( calculate_remaining_offer_balance, generate_offer_display_name, @@ -43,6 +46,8 @@ get_enterprise_customer_uuid_from_voucher ) from ecommerce.entitlements.utils import create_or_update_course_entitlement +from ecommerce.extensions.api.constatnts import MAIL_MOBILE_TEAM_FOR_CHANGE_IN_COURSE +from ecommerce.extensions.api.utils import send_mail_to_mobile_team_for_change_in_course from ecommerce.extensions.api.v2.constants import ( ENABLE_HOIST_ORDER_HISTORY, REFUND_ORDER_EMAIL_CLOSING, @@ -817,6 +822,13 @@ def validate_products(self, products): return products + def _get_seats_offered_on_mobile(self, course): + certificate_type_query = Q(attributes__name='certificate_type', attribute_values__value_text='verified') + mobile_query = Q(stockrecords__partner_sku__contains='mobile') + mobile_seats = course.seat_products.filter(certificate_type_query & mobile_query) + + return mobile_seats + def get_partner(self): """Validate partner""" if not self.partner: @@ -876,6 +888,10 @@ def save(self): # pylint: disable=arguments-differ published = (resp_message is None) if published: + mobile_seats = self._get_seats_offered_on_mobile(course) + if waffle.switch_is_active(MAIL_MOBILE_TEAM_FOR_CHANGE_IN_COURSE) and mobile_seats: + send_mail_to_mobile_team_for_change_in_course(course, mobile_seats) + return created, None, None raise Exception(resp_message) @@ -1137,7 +1153,7 @@ class Meta: def _serialize_remaining_balance_value(conditional_offer): """ - Change value into string and return it unless it is None. + Calculate and return remaining balance on the offer. """ remaining_balance = calculate_remaining_offer_balance(conditional_offer) if remaining_balance is not None: @@ -1145,38 +1161,63 @@ def _serialize_remaining_balance_value(conditional_offer): return remaining_balance -class EnterpriseLearnerOfferApiSerializer(serializers.BaseSerializer): # pylint: disable=abstract-method +def _serialize_remaining_balance_for_user(conditional_offer, request): """ - Serializer for EnterpriseOffer learner endpoint. + Determines the remaining balance for the user. + """ + if request and conditional_offer.max_user_discount is not None: + return str(conditional_offer.max_user_discount - sum_user_discounts_for_offer(request.user, conditional_offer)) + return None - Uses serializers.BaseSerializer to keep this lightweight. + +def _serialize_remaining_applications_value(conditional_offer): """ + Calculate and return remaining number of applications on the offer. + """ + if conditional_offer.max_global_applications is not None: + return conditional_offer.max_global_applications - conditional_offer.num_applications + return None - def _serialize_remaining_balance_for_user(self, instance): - request = self.context.get('request') - if request and instance.max_user_discount is not None: - return str(instance.max_user_discount - sum_user_discounts_for_offer(request.user, instance)) +def _serialize_remaining_applications_for_user(conditional_offer, request): + """ + Determines the remaining number of applications (enrollments) for the user. + """ + if request and conditional_offer.max_user_applications is not None: + return conditional_offer.max_user_applications - conditional_offer.get_num_user_applications(request.user) + return None - return None + +class EnterpriseLearnerOfferApiSerializer(serializers.BaseSerializer): # pylint: disable=abstract-method + """ + Serializer for EnterpriseOffer learner endpoint. + + Uses serializers.BaseSerializer to keep this lightweight. + """ def to_representation(self, instance): representation = OrderedDict() representation['id'] = instance.id - representation['max_discount'] = instance.max_discount + representation['enterprise_customer_uuid'] = instance.condition.enterprise_customer_uuid + representation['enterprise_catalog_uuid'] = instance.condition.enterprise_customer_catalog_uuid + representation['is_current'] = instance.is_current + representation['status'] = instance.status representation['start_datetime'] = instance.start_datetime representation['end_datetime'] = instance.end_datetime - representation['enterprise_catalog_uuid'] = instance.condition.enterprise_customer_catalog_uuid representation['usage_type'] = get_benefit_type(instance.benefit) representation['discount_value'] = instance.benefit.value - representation['status'] = instance.status - representation['remaining_balance'] = _serialize_remaining_balance_value(instance) - representation['is_current'] = instance.is_current + representation['max_discount'] = instance.max_discount representation['max_global_applications'] = instance.max_global_applications + representation['max_user_applications'] = instance.max_user_applications representation['max_user_discount'] = instance.max_user_discount representation['num_applications'] = instance.num_applications - representation['remaining_balance_for_user'] = self._serialize_remaining_balance_for_user(instance) + representation['remaining_balance'] = _serialize_remaining_balance_value(instance) + representation['remaining_applications'] = _serialize_remaining_applications_value(instance) + representation['remaining_balance_for_user'] = \ + _serialize_remaining_balance_for_user(instance, request=self.context.get('request')) + representation['remaining_applications_for_user'] = \ + _serialize_remaining_applications_for_user(instance, request=self.context.get('request')) return representation @@ -1199,6 +1240,7 @@ def to_representation(self, instance): representation['enterprise_catalog_uuid'] = instance.condition.enterprise_customer_catalog_uuid representation['display_name'] = generate_offer_display_name(instance) representation['remaining_balance'] = _serialize_remaining_balance_value(instance) + representation['remaining_applications'] = _serialize_remaining_applications_value(instance) representation['is_current'] = instance.is_current return representation @@ -1464,6 +1506,7 @@ class CouponSerializer(CouponMixin, ProductPaymentInfoMixin, serializers.ModelSe contract_discount_type = serializers.SerializerMethodField() prepaid_invoice_amount = serializers.SerializerMethodField() sales_force_id = serializers.SerializerMethodField() + salesforce_opportunity_line_item = serializers.SerializerMethodField() class Meta: model = Product @@ -1473,7 +1516,8 @@ class Meta: 'end_date', 'enterprise_catalog_content_metadata_url', 'enterprise_customer', 'enterprise_customer_catalog', 'id', 'inactive', 'last_edited', 'max_uses', 'note', 'notify_email', 'num_uses', 'payment_information', 'program_uuid', 'price', 'quantity', 'seats', 'start_date', 'title', 'voucher_type', - 'contract_discount_value', 'contract_discount_type', 'prepaid_invoice_amount', 'sales_force_id', + 'contract_discount_value', 'contract_discount_type', 'prepaid_invoice_amount', + 'sales_force_id', 'salesforce_opportunity_line_item', ) def get_prepaid_invoice_amount(self, obj): @@ -1601,6 +1645,13 @@ def get_sales_force_id(self, obj): except AttributeError: return None + def get_salesforce_opportunity_line_item(self, obj): + """ Get the Salesforce Opportunity Line Item attached to the coupon. """ + try: + return obj.attr.salesforce_opportunity_line_item + except AttributeError: + return None + def get_num_uses(self, obj): offer = retrieve_offer(obj) return offer.num_applications @@ -1650,6 +1701,18 @@ def validate_sales_force_id_format(self): 'sales_force_id': 'Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006.' }) + def validate_salesforce_opportunity_line_item_format(self): + """ + Validate salesforce_opportunity_line_item format + """ + salesforce_opportunity_line_item = self.initial_data.get('salesforce_opportunity_line_item') + if salesforce_opportunity_line_item and not\ + re.match(ENTERPRISE_SALESFORCE_OPPORTUNITY_LINE_ITEM_REGEX, salesforce_opportunity_line_item): + raise ValidationError({ + 'salesforce_opportunity_line_item': + 'Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.' + }) + class CouponUpdateSerializer(CouponSerializer): """ @@ -1689,13 +1752,19 @@ def validate(self, attrs): """ validated_data = super(EnterpriseCouponCreateSerializer, self).validate(attrs) - # Validate sales_force_id - sales_force_id = self.initial_data.get('sales_force_id') - if not sales_force_id: + # Validate salesforce_opportunity_line_item + salesforce_opportunity_line_item = self.initial_data.get('salesforce_opportunity_line_item') + if not salesforce_opportunity_line_item: raise ValidationError({ - 'sales_force_id': 'This field is required.' + 'salesforce_opportunity_line_item': 'This field is required.' }) - self.validate_sales_force_id_format() + self.validate_salesforce_opportunity_line_item_format() + + # Validate sales_force_id if it exists + sales_force_id = self.initial_data.get('sales_force_id') + if sales_force_id: + self.validate_sales_force_id_format() + return validated_data @@ -1709,13 +1778,18 @@ def validate(self, attrs): """ validated_data = super(EnterpriseCouponUpdateSerializer, self).validate(attrs) - # Validate sales_force_id - sales_force_id = self.initial_data.get('sales_force_id') - if 'sales_force_id' in self.initial_data and not sales_force_id: + # Validate salesforce_opportunity_line_item + salesforce_opportunity_line_item = self.initial_data.get('salesforce_opportunity_line_item') + if 'salesforce_opportunity_line_item' in self.initial_data and not salesforce_opportunity_line_item: raise ValidationError({ - 'sales_force_id': 'This field is required.' + 'salesforce_opportunity_line_item': 'This field is required.' }) - self.validate_sales_force_id_format() + self.validate_salesforce_opportunity_line_item_format() + + # Validate sales_force_id if it exists + sales_force_id = self.initial_data.get('sales_force_id') + if sales_force_id: + self.validate_sales_force_id_format() return validated_data diff --git a/ecommerce/extensions/api/tests/test_handlers.py b/ecommerce/extensions/api/tests/test_handlers.py deleted file mode 100644 index 8c19df501be..00000000000 --- a/ecommerce/extensions/api/tests/test_handlers.py +++ /dev/null @@ -1,146 +0,0 @@ -""" Tests for handler functions. """ - - -from time import time - -import jwt -import mock -from django.conf import settings -from django.test import override_settings -from waffle.testutils import override_switch - -from ecommerce.extensions.api.handlers import jwt_decode_handler -from ecommerce.tests.factories import UserFactory -from ecommerce.tests.testcases import TestCase - - -def generate_jwt_token(payload, signing_key): - """Generate a valid JWT token for authenticated requests.""" - return jwt.encode(payload, signing_key).decode('utf-8') - - -def generate_jwt_payload(user, issuer_name): - """Generate a valid JWT payload given a user.""" - now = int(time()) - ttl = 5 - return { - 'iss': issuer_name, - 'username': user.username, - 'email': user.email, - 'iat': now, - 'exp': now + ttl - } - - -class JWTDecodeHandlerTests(TestCase): - """ Tests for the `jwt_decode_handler` utility function. """ - - def setUp(self): - super(JWTDecodeHandlerTests, self).setUp() - self.user = UserFactory() - - @override_settings( - JWT_AUTH={ - 'JWT_ISSUERS': [{ - 'AUDIENCE': 'test-audience', - 'ISSUER': 'test-issuer', - 'SECRET_KEY': 'test-secret-key', - }], - 'JWT_VERIFY_AUDIENCE': False, - } - ) - @mock.patch('edx_django_utils.monitoring.set_custom_metric') - @mock.patch('ecommerce.extensions.api.handlers._ecommerce_jwt_decode_handler_multiple_issuers') - def test_decode_success_edx_drf_extensions(self, mock_multiple_issuer_decoder, mock_set_custom_metric): - """ - Should pass using the edx-drf-extensions jwt_decode_handler. - - This would happen if ``_ecommerce_jwt_decode_handler_multiple_issuers`` - should fail (e.g. using asymmetric tokens). - """ - mock_multiple_issuer_decoder.side_effect = jwt.InvalidTokenError() - first_issuer = settings.JWT_AUTH['JWT_ISSUERS'][0] - payload = generate_jwt_payload(self.user, issuer_name=first_issuer['ISSUER']) - token = generate_jwt_token(payload, first_issuer['SECRET_KEY']) - self.assertDictContainsSubset(payload, jwt_decode_handler(token)) - mock_set_custom_metric.assert_called_with('ecom_jwt_decode_handler', 'edx-drf-extensions') - - @override_settings( - JWT_AUTH={ - 'JWT_ISSUERS': [ - { - 'AUDIENCE': 'test-audience', - 'ISSUER': 'test-issuer', - 'SECRET_KEY': 'test-secret-key', - }, - { - 'AUDIENCE': 'test-audience', - 'ISSUER': 'test-invalid-issuer', - 'SECRET_KEY': 'test-secret-key-2', - }, - { - 'AUDIENCE': 'test-audience', - 'ISSUER': 'test-issuer-2', - 'SECRET_KEY': 'test-secret-key-2', - }, - ], - 'JWT_VERIFY_AUDIENCE': False, - } - ) - @mock.patch('edx_django_utils.monitoring.set_custom_metric') - @mock.patch('ecommerce.extensions.api.handlers.logger') - def test_decode_success_multiple_issuers(self, mock_logger, mock_set_custom_metric): - """ - Should pass using ``_ecommerce_jwt_decode_handler_multiple_issuers``. - - This would happen with the combination of the JWT_ISSUERS configured in - the way that edx-drf-extensions is expected, but when the token was - generated from the second (or third+) issuer. - """ - non_first_issuer = settings.JWT_AUTH['JWT_ISSUERS'][2] - payload = generate_jwt_payload(self.user, issuer_name=non_first_issuer['ISSUER']) - token = generate_jwt_token(payload, non_first_issuer['SECRET_KEY']) - self.assertDictContainsSubset(payload, jwt_decode_handler(token)) - mock_set_custom_metric.assert_called_with('ecom_jwt_decode_handler', 'ecommerce-multiple-issuers') - mock_logger.exception.assert_not_called() - mock_logger.warning.assert_not_called() - mock_logger.error.assert_not_called() - mock_logger.info.assert_not_called() - - @override_settings( - JWT_AUTH={ - 'JWT_ISSUERS': [ - { - 'AUDIENCE': 'test-audience', - 'ISSUER': 'test-issuer', - 'SECRET_KEY': 'test-secret-key', - }, - { - 'AUDIENCE': 'test-audience', - 'ISSUER': 'test-issuer-2', - 'SECRET_KEY': 'test-secret-key-2', - }, - ], - 'JWT_VERIFY_AUDIENCE': False, - } - ) - @override_switch('jwt_decode_handler.log_exception.ecommerce-multiple-issuers', active=True) - @override_switch('jwt_decode_handler.log_exception.edx-drf-extensions', active=True) - @mock.patch('ecommerce.extensions.api.handlers.logger') - def test_decode_error_invalid_token(self, mock_logger): - """ - Invalid token will fail both multiple-issuers and the fallback of - edx-drf-extensions jwt_decode_handler. - """ - # Update the payload to ensure a validation error - payload = generate_jwt_payload(self.user, issuer_name='test-issuer-2') - payload['exp'] = 0 - token = generate_jwt_token(payload, 'test-secret-key-2') - with self.assertRaises(jwt.InvalidTokenError): - jwt_decode_handler(token) - - mock_logger.exception.assert_called_with('Custom config JWT decode failed!') - mock_logger.info.assert_has_calls(calls=[ - mock.call('Failed to use ecommerce multiple issuer jwt_decode_handler.', exc_info=True), - mock.call('Failed to use edx-drf-extensions jwt_decode_handler.', exc_info=True), - ]) diff --git a/ecommerce/extensions/api/tests/test_utils.py b/ecommerce/extensions/api/tests/test_utils.py new file mode 100644 index 00000000000..cc81c0293d1 --- /dev/null +++ b/ecommerce/extensions/api/tests/test_utils.py @@ -0,0 +1,62 @@ +import mock +from testfixtures import LogCapture + +from ecommerce.courses.tests.factories import CourseFactory +from ecommerce.extensions.api.utils import send_mail_to_mobile_team_for_change_in_course +from ecommerce.extensions.iap.models import IAPProcessorConfiguration +from ecommerce.tests.testcases import TestCase + + +class UtilTests(TestCase): + def setUp(self): + super(UtilTests, self).setUp() + self.course = CourseFactory(id='test/course/123', name='Test Course 123') + seat = self.course.create_or_update_seat('verified', True, 60) + second_seat = self.course.create_or_update_seat('verified', True, 70) + self.mock_mobile_team_mail = 'abc@example.com' + self.mock_email_body = { + 'subject': 'Course Change Alert for Test Course 123', + 'body': 'Course: Test Course 123, Sku: {}, Price: 70.00\n' + 'Course: Test Course 123, Sku: {}, Price: 60.00'.format( + second_seat.stockrecords.all()[0].partner_sku, + seat.stockrecords.all()[0].partner_sku + ) + } + + def test_send_mail_to_mobile_team_with_no_email_specified(self): + logger_name = 'ecommerce.extensions.api.utils' + email_sender = 'ecommerce.extensions.communication.utils.Dispatcher.dispatch_direct_messages' + msg_t = "Couldn't mail mobile team for change in {}. No email was specified for mobile team in configurations" + msg = msg_t.format(self.course.name) + with LogCapture(logger_name) as utils_logger,\ + mock.patch(email_sender) as mock_send_email: + + send_mail_to_mobile_team_for_change_in_course(self.course, self.course.seat_products.all()) + utils_logger.check_present( + ( + logger_name, + 'INFO', + msg + ) + ) + assert mock_send_email.call_count == 0 + + def test_send_mail_to_mobile_team(self): + logger_name = 'ecommerce.extensions.api.utils' + email_sender = 'ecommerce.extensions.communication.utils.Dispatcher.dispatch_direct_messages' + iap_configs = IAPProcessorConfiguration.get_solo() + iap_configs.mobile_team_email = self.mock_mobile_team_mail + iap_configs.save() + with LogCapture(logger_name) as utils_logger,\ + mock.patch(email_sender) as mock_send_email: + + send_mail_to_mobile_team_for_change_in_course(self.course, self.course.seat_products.all()) + utils_logger.check_present( + ( + logger_name, + 'INFO', + "Sent change in {} email to mobile team.".format(self.course.name) + ) + ) + assert mock_send_email.call_count == 1 + mock_send_email.assert_called_with(self.mock_mobile_team_mail, self.mock_email_body) diff --git a/ecommerce/extensions/api/throttles.py b/ecommerce/extensions/api/throttles.py index 138093b7f2a..5a322da8fe2 100644 --- a/ecommerce/extensions/api/throttles.py +++ b/ecommerce/extensions/api/throttles.py @@ -14,7 +14,8 @@ def allow_request(self, request, view): service_users = [ settings.ECOMMERCE_SERVICE_WORKER_USERNAME, settings.PROSPECTUS_WORKER_USERNAME, - settings.DISCOVERY_WORKER_USERNAME + settings.DISCOVERY_WORKER_USERNAME, + settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME ] if request.user.username in service_users: return True diff --git a/ecommerce/extensions/api/utils.py b/ecommerce/extensions/api/utils.py new file mode 100644 index 00000000000..d3ce55b71eb --- /dev/null +++ b/ecommerce/extensions/api/utils.py @@ -0,0 +1,36 @@ +import logging + +from oscar.core.loading import get_class + +from ecommerce.extensions.iap.models import IAPProcessorConfiguration + +Dispatcher = get_class('communication.utils', 'Dispatcher') +logger = logging.getLogger(__name__) + + +def send_mail_to_mobile_team_for_change_in_course(course, seats): + recipient = IAPProcessorConfiguration.get_solo().mobile_team_email + if not recipient: + msg = "Couldn't mail mobile team for change in %s. No email was specified for mobile team in configurations" + logger.info(msg, course.name) + return + + def format_seat(seat): + seat_template = "Course: {}, Sku: {}, Price: {}" + stock_record = seat.stockrecords.all()[0] + result = seat_template.format( + course.name, + stock_record.partner_sku, + stock_record.price_excl_tax, + ) + return result + + formatted_seats = [format_seat(seat) for seat in seats if seat.stockrecords.all()] + + messages = { + 'subject': 'Course Change Alert for {}'.format(course.name), + 'body': "\n".join(formatted_seats) + } + + Dispatcher().dispatch_direct_messages(recipient, messages) + logger.info("Sent change in %s email to mobile team.", course.name) diff --git a/ecommerce/extensions/api/v2/tests/test_serializers.py b/ecommerce/extensions/api/v2/tests/test_serializers.py index fb3547e5d33..448cc6aebd6 100644 --- a/ecommerce/extensions/api/v2/tests/test_serializers.py +++ b/ecommerce/extensions/api/v2/tests/test_serializers.py @@ -17,18 +17,19 @@ class EnterpriseLearnerOfferApiSerializerTests(TestCase): @ddt.data( - (100, '74.5'), - (None, None) + (100, 25.5, '74.5'), + (None, None, None) ) @ddt.unpack @mock.patch('ecommerce.extensions.api.serializers.sum_user_discounts_for_offer') def test_serialize_remaining_balance_for_user( self, max_user_discount, - expected_remaining_balance, + existing_user_spend, + expected_remaining_balance_for_user, mock_sum_user_discounts_for_offer ): - mock_sum_user_discounts_for_offer.return_value = 25.5 + mock_sum_user_discounts_for_offer.return_value = existing_user_spend enterprise_customer_uuid = str(uuid4()) condition = extended_factories.EnterpriseCustomerConditionFactory( enterprise_customer_uuid=enterprise_customer_uuid @@ -45,4 +46,90 @@ def test_serialize_remaining_balance_for_user( } ) data = serializer.to_representation(enterprise_offer) - assert data['remaining_balance_for_user'] == expected_remaining_balance + assert data['remaining_balance_for_user'] == expected_remaining_balance_for_user + + @ddt.data( + (5250, '5250'), + (None, None) + ) + @ddt.unpack + @mock.patch('ecommerce.extensions.api.serializers.calculate_remaining_offer_balance') + def test_serialize_remaining_balance( + self, + max_discount, + expected_remaining_balance, + mock_calculate_remaining_offer_balance + ): + mock_calculate_remaining_offer_balance.return_value = max_discount + enterprise_customer_uuid = str(uuid4()) + condition = extended_factories.EnterpriseCustomerConditionFactory( + enterprise_customer_uuid=enterprise_customer_uuid + ) + enterprise_offer = extended_factories.EnterpriseOfferFactory.create( + partner=self.partner, + condition=condition, + ) + serializer = EnterpriseLearnerOfferApiSerializer( + data=enterprise_offer, + context={ + 'request': mock.MagicMock(user=UserFactory()) + } + ) + data = serializer.to_representation(enterprise_offer) + assert data['remaining_balance'] == expected_remaining_balance + + @ddt.data( + (1000, 1000), + (None, None) + ) + @ddt.unpack + def test_serialize_remaining_applications_for_user( + self, + max_user_applications, + expected_remaining_applications_for_user, + ): + enterprise_customer_uuid = str(uuid4()) + condition = extended_factories.EnterpriseCustomerConditionFactory( + enterprise_customer_uuid=enterprise_customer_uuid + ) + enterprise_offer = extended_factories.EnterpriseOfferFactory.create( + partner=self.partner, + condition=condition, + max_user_applications=max_user_applications, + ) + serializer = EnterpriseLearnerOfferApiSerializer( + data=enterprise_offer, + context={ + 'request': mock.MagicMock(user=UserFactory()) + } + ) + data = serializer.to_representation(enterprise_offer) + assert data['remaining_applications_for_user'] == expected_remaining_applications_for_user + + @ddt.data( + (2, 2), + (None, None) + ) + @ddt.unpack + def test_serialize_remaining_applications( + self, + max_global_applications, + expected_remaining_applications, + ): + enterprise_customer_uuid = str(uuid4()) + condition = extended_factories.EnterpriseCustomerConditionFactory( + enterprise_customer_uuid=enterprise_customer_uuid + ) + enterprise_offer = extended_factories.EnterpriseOfferFactory.create( + partner=self.partner, + condition=condition, + max_global_applications=max_global_applications, + ) + serializer = EnterpriseLearnerOfferApiSerializer( + data=enterprise_offer, + context={ + 'request': mock.MagicMock(user=UserFactory()) + } + ) + data = serializer.to_representation(enterprise_offer) + assert data['remaining_applications'] == expected_remaining_applications diff --git a/ecommerce/extensions/api/v2/tests/views/test_coupons.py b/ecommerce/extensions/api/v2/tests/views/test_coupons.py index 8982eff56ca..93739a48c7e 100644 --- a/ecommerce/extensions/api/v2/tests/views/test_coupons.py +++ b/ecommerce/extensions/api/v2/tests/views/test_coupons.py @@ -88,6 +88,7 @@ def setUp(self): 'email_domains': None, 'program_uuid': None, 'sales_force_id': None, + 'salesforce_opportunity_line_item': None, } def test_clean_voucher_request_data(self): @@ -107,6 +108,7 @@ def test_clean_voucher_request_data(self): 'max_uses': 1, 'notify_email': 'batman@gotham.comics', 'sales_force_id': 'salesforceid123', + 'salesforce_opportunity_line_item': 'salesforcelineitem123', }) view = CouponViewSet() cleaned_voucher_data = view.clean_voucher_request_data(self.coupon_data, self.site.siteconfiguration.partner) @@ -139,6 +141,7 @@ def test_clean_voucher_request_data(self): 'contract_discount_type', 'contract_discount_value', 'sales_force_id', + 'salesforce_opportunity_line_item', ] self.assertEqual(sorted(expected_cleaned_voucher_data_keys), sorted(cleaned_voucher_data.keys())) @@ -245,6 +248,7 @@ def setUp(self): 'title': 'Tešt čoupon', 'voucher_type': Voucher.SINGLE_USE, 'sales_force_id': '006ABCDE0123456789', + 'salesforce_opportunity_line_item': '00kABCDE9876543210', } self.response = self.get_response('POST', COUPONS_LINK, self.data) self.coupon = Product.objects.get(title=self.data['title']) @@ -693,7 +697,7 @@ def test_update_note(self): self.assertEqual(new_coupon.attr.note, note) @ddt.data( - '006abcde0123456789', 'otherSalesForce', '' + '00kabcde0123456789', 'otherSalesForce', '' ) def test_update_sales_force_id(self, sales_force_id): """ @@ -1257,7 +1261,6 @@ def test_create_coupon_with_contract_discount_metadata(self): enterprise_name, ENTERPRISE_COUPONS_LINK ) - coupon = Product.objects.get(id=response.json()['coupon_id']) assert coupon.attr.enterprise_contract_metadata.discount_value == Decimal('12.34000') assert coupon.attr.enterprise_contract_metadata.discount_type == 'Percentage' diff --git a/ecommerce/extensions/api/v2/tests/views/test_courses.py b/ecommerce/extensions/api/v2/tests/views/test_courses.py index 885fb65f173..c20a75ef340 100644 --- a/ecommerce/extensions/api/v2/tests/views/test_courses.py +++ b/ecommerce/extensions/api/v2/tests/views/test_courses.py @@ -2,9 +2,7 @@ import json -import jwt import mock -from django.conf import settings from django.contrib.auth import get_user_model from django.urls import reverse from oscar.core.loading import get_class, get_model @@ -16,6 +14,7 @@ from ecommerce.courses.tests.factories import CourseFactory from ecommerce.extensions.api.v2.tests.views import JSON_CONTENT_TYPE, ProductSerializerMixin from ecommerce.extensions.catalogue.tests.mixins import DiscoveryTestMixin +from ecommerce.tests.mixins import JwtMixin from ecommerce.tests.testcases import TestCase Product = get_model('catalogue', 'Product') @@ -24,7 +23,7 @@ User = get_user_model() -class CourseViewSetTests(ProductSerializerMixin, DiscoveryTestMixin, TestCase): +class CourseViewSetTests(JwtMixin, ProductSerializerMixin, DiscoveryTestMixin, TestCase): list_path = reverse('api:v2:course-list') def setUp(self): @@ -75,14 +74,9 @@ def test_jwt_authentication(self): """ Verify the endpoint supports JWT authentication and user creation. """ username = 'some-user' email = 'some-user@example.com' - payload = { - 'administrator': True, - 'username': username, - 'email': email, - 'iss': settings.JWT_AUTH['JWT_ISSUERS'][0]['ISSUER'] - } - auth_header = "JWT {token}".format( - token=jwt.encode(payload, settings.JWT_AUTH['JWT_SECRET_KEY']).decode('utf-8')) + is_staff = True + + auth_header = f'JWT {self.generate_new_user_token(username, email, is_staff)}' self.assertFalse(User.objects.filter(username=username).exists()) response = self.client.get( diff --git a/ecommerce/extensions/api/v2/tests/views/test_enterprise.py b/ecommerce/extensions/api/v2/tests/views/test_enterprise.py index 851a68d89e5..070892e6122 100644 --- a/ecommerce/extensions/api/v2/tests/views/test_enterprise.py +++ b/ecommerce/extensions/api/v2/tests/views/test_enterprise.py @@ -323,6 +323,7 @@ def setUp(self): 'contract_discount_value': '12.35', 'notify_learners': True, 'sales_force_id': '006ABCDE0123456789', + 'salesforce_opportunity_line_item': '00kABCDE9876543210', } self.course = CourseFactory(id='course-v1:test-org+course+run', partner=self.partner) @@ -394,7 +395,6 @@ def _test_sales_force_id_on_update_coupon(self, sales_force_id, expected_status_ ('006abcde0123456789', 200, None), ('006ABCDE0123456789', 200, None), ('none', 200, None), - (None, 400, 'This field is required.'), ( '006ABCDE012345678123143', 400, @@ -412,8 +412,81 @@ def test_sales_force_id(self, sales_force_id, expected_status_code, error_mesg): self._test_sales_force_id_on_create_coupon(sales_force_id, expected_status_code, error_mesg) self._test_sales_force_id_on_update_coupon(sales_force_id, expected_status_code, error_mesg) - def test_sales_force_id_missing_sales_force_id(self): - self._test_sales_force_id_on_create_coupon('', 400, 'This field is required.', add_sales_forces_id_param=False) + def _test_salesforce_opportunity_line_item_on_create_coupon(self, salesforce_opportunity_line_item, + expected_status_code, expected_error, + add_sales_forces_id_param=True): + """ + Test sales force id with creating the Enterprise Coupon. + """ + data = {**self.data} + del data['salesforce_opportunity_line_item'] + if add_sales_forces_id_param: + data['salesforce_opportunity_line_item'] = salesforce_opportunity_line_item + response = self.get_response('POST', ENTERPRISE_COUPONS_LINK, data) + self.assertEqual(response.status_code, expected_status_code) + response = response.json() + if expected_status_code == status.HTTP_400_BAD_REQUEST: + self.assertEqual(response['salesforce_opportunity_line_item'][0], expected_error) + else: + coupon = Product.objects.get(pk=response['coupon_id']) + self.assertEqual(coupon.attr.salesforce_opportunity_line_item, salesforce_opportunity_line_item) + + def _test_salesforce_opportunity_line_item_on_update_coupon( + self, salesforce_opportunity_line_item, expected_status_code, expected_error): + """ + Test sales force id with updating the Enterprise Coupon. + """ + coupon_response = self.get_response('POST', ENTERPRISE_COUPONS_LINK, self.data) + coupon_response = coupon_response.json() + coupon_id = coupon_response['coupon_id'] + coupon = Product.objects.get(pk=coupon_id) + + response = self.get_response( + 'PUT', + reverse('api:v2:enterprise-coupons-detail', kwargs={'pk': coupon.id}), + data={ + 'salesforce_opportunity_line_item': salesforce_opportunity_line_item + } + ) + self.assertEqual(response.status_code, expected_status_code) + + response = response.json() + if expected_status_code == status.HTTP_400_BAD_REQUEST: + self.assertEqual(response['salesforce_opportunity_line_item'][0], expected_error) + else: + coupon.refresh_from_db() + self.assertEqual(coupon.attr.salesforce_opportunity_line_item, salesforce_opportunity_line_item) + + @ddt.data( + ('00kabcde0123456789', 200, None), + ('00kABCDE0123456789', 200, None), + ('none', 200, None), + (None, 400, 'This field is required.'), + ( + '006ABCDE012345678123143', + 400, + 'Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.' + ), + ('006ABCDE01234', 400, + 'Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ('a0KABCDE0123456789', 400, + 'Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ('006ABCDE0 12345678', 400, + 'Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'.'), + ) + @ddt.unpack + def test_salesforce_opportunity_line_item(self, salesforce_opportunity_line_item, expected_status_code, error_mesg): + """ + Test sales force id. + """ + self._test_salesforce_opportunity_line_item_on_create_coupon( + salesforce_opportunity_line_item, expected_status_code, error_mesg) + self._test_salesforce_opportunity_line_item_on_update_coupon( + salesforce_opportunity_line_item, expected_status_code, error_mesg) + + def test_salesforce_opportunity_line_item_missing_salesforce_opportunity_line_item(self): + self._test_salesforce_opportunity_line_item_on_create_coupon( + '', 400, 'This field is required.', add_sales_forces_id_param=False) def get_coupon_data(self, coupon_title): """ diff --git a/ecommerce/extensions/api/v2/tests/views/test_refunds.py b/ecommerce/extensions/api/v2/tests/views/test_refunds.py index d596b1a4180..df04e8bf56c 100644 --- a/ecommerce/extensions/api/v2/tests/views/test_refunds.py +++ b/ecommerce/extensions/api/v2/tests/views/test_refunds.py @@ -19,14 +19,14 @@ from ecommerce.extensions.refund.tests.factories import RefundFactory, RefundLineFactory from ecommerce.extensions.refund.tests.mixins import RefundTestMixin from ecommerce.extensions.test.factories import create_order -from ecommerce.tests.mixins import JwtMixin, ThrottlingMixin +from ecommerce.tests.mixins import ThrottlingMixin from ecommerce.tests.testcases import TestCase Option = get_model('catalogue', 'Option') Refund = get_model('refund', 'Refund') -class RefundCreateViewTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase): +class RefundCreateViewTests(RefundTestMixin, AccessTokenMixin, TestCase): MODEL_LOGGER_NAME = 'ecommerce.core.models' path = reverse('api:v2:refunds:create') @@ -104,7 +104,7 @@ def test_jwt_authentication(self): self.client.logout() data = self._get_data(self.user.username, self.course_id) - auth_header = 'JWT ' + self.generate_token({'username': self.user.username}) + auth_header = self.generate_jwt_token_header(self.user) response = self.client.post(self.path, data, JSON_CONTENT_TYPE, HTTP_AUTHORIZATION=auth_header) self.assert_ok_response(response) diff --git a/ecommerce/extensions/api/v2/views/coupons.py b/ecommerce/extensions/api/v2/views/coupons.py index 33ff3ca9c4f..16f5a59b536 100644 --- a/ecommerce/extensions/api/v2/views/coupons.py +++ b/ecommerce/extensions/api/v2/views/coupons.py @@ -171,6 +171,7 @@ def create_coupon_and_vouchers(self, cleaned_voucher_data): program_uuid=cleaned_voucher_data['program_uuid'], site=self.request.site, sales_force_id=cleaned_voucher_data['sales_force_id'], + salesforce_opportunity_line_item=cleaned_voucher_data['salesforce_opportunity_line_item'], ) def validate_access_for_enterprise(self, request_data): @@ -274,6 +275,7 @@ def clean_voucher_request_data(cls, request_data, partner): 'contract_discount_value': request_data.get('contract_discount_value'), 'prepaid_invoice_amount': request_data.get('prepaid_invoice_amount'), 'sales_force_id': request_data.get('sales_force_id'), + 'salesforce_opportunity_line_item': request_data.get('salesforce_opportunity_line_item'), } @classmethod @@ -478,6 +480,10 @@ def update_coupon_product_data(self, request_data, coupon): if sales_force_id is not None: coupon.attr.sales_force_id = sales_force_id + salesforce_opportunity_line_item = request_data.get('salesforce_opportunity_line_item') + if salesforce_opportunity_line_item is not None: + coupon.attr.salesforce_opportunity_line_item = salesforce_opportunity_line_item + if 'inactive' in request_data: coupon.attr.inactive = request_data.get('inactive') diff --git a/ecommerce/extensions/api/v2/views/enterprise.py b/ecommerce/extensions/api/v2/views/enterprise.py index 4457461701b..f71f2fe6dd8 100644 --- a/ecommerce/extensions/api/v2/views/enterprise.py +++ b/ecommerce/extensions/api/v2/views/enterprise.py @@ -298,7 +298,8 @@ def create_coupon_and_vouchers(self, cleaned_voucher_data): cleaned_voucher_data['note'], cleaned_voucher_data.get('notify_email'), cleaned_voucher_data['enterprise_customer'], - cleaned_voucher_data['sales_force_id'] + cleaned_voucher_data['sales_force_id'], + cleaned_voucher_data['salesforce_opportunity_line_item'], ) logger.info( "Calling attach_or_update_contract_metadata_on_coupon " diff --git a/ecommerce/extensions/api/v2/views/orders.py b/ecommerce/extensions/api/v2/views/orders.py index ed17492a9e8..17eba2919e5 100644 --- a/ecommerce/extensions/api/v2/views/orders.py +++ b/ecommerce/extensions/api/v2/views/orders.py @@ -125,6 +125,7 @@ class ManualCourseEnrollmentOrderViewSet(EdxOrderPlacementMixin, EnterpriseDisco >>> "discount_percentage": 75.0, >>> "date_placed": '2020-02-11T09:38:47.634561+00:00', # optional param, only for old records. >>> "sales_force_id": '252F0060L00000ppWfu', + >>> "salesforce_opportunity_line_item": '00kF0060L00000ppWfu', >>> "mode": 'verified', >>> "enterprise_customer_name": "an-enterprise-customer", >>> "enterprise_customer_uuid": "394a5ce5-6ff4-4b2b-bea1-a273c6920ae1", @@ -152,6 +153,7 @@ class ManualCourseEnrollmentOrderViewSet(EdxOrderPlacementMixin, EnterpriseDisco >>> "discount_percentage": 75.0, >>> "date_placed": '2020-02-11T09:38:47.634561+00:00', >>> "sales_force_id": '252F0060L00000ppWfu', + >>> "salesforce_opportunity_line_item": '00kF0060L00000ppWfu', >>> "mode": 'verified', >>> "enterprise_customer_name": "an-enterprise-customer", >>> "enterprise_customer_uuid": "394a5ce5-6ff4-4b2b-bea1-a273c6920ae1", @@ -232,6 +234,7 @@ def _create_single_order(self, enrollment, request_user, request_site): "course_run_key": , "discount_percentage": , "sales_force_id": , + "salesforce_opportunity_line_item": , "mode": , "enterprise_customer_name": , "enterprise_customer_uuid": , @@ -251,19 +254,21 @@ def _create_single_order(self, enrollment, request_user, request_site): mode, discount_percentage, sales_force_id, + salesforce_opportunity_line_item, ) = self._get_enrollment_data(enrollment) except ValidationError as ex: return dict(enrollment, status=self.FAILURE, detail=ex.message, new_order_created=None) logger.info( '[Manual Order Creation] Request received. User: %s, Email: %s, Course: %s, RequestUser: %s, ' - 'Discount Percentage: %s, Salesforce Opportunity Id: %s', + 'Discount Percentage: %s, Salesforce Opportunity Id: %s, Salesforce Opportunity Line Item Id: %s', learner_username, learner_email, course_run_key, request_user.username, discount_percentage, sales_force_id, + salesforce_opportunity_line_item, ) learner_user = self._get_learner_user(lms_user_id, learner_username, learner_email) @@ -308,7 +313,8 @@ def _create_single_order(self, enrollment, request_user, request_site): discount_offer = self._get_or_create_discount_offer( enterprise_customer_name, enterprise_customer_uuid, - sales_force_id + sales_force_id, + salesforce_opportunity_line_item ) Applicator().apply_offers(basket, [discount_offer]) try: @@ -399,6 +405,7 @@ def _get_enrollment_data(self, enrollment): Optional Parameters: discount_percentage: Discounted percentage for manual enrollment. sales_force_id: Salesforce opportunity id. + salesforce_opportunity_line_item: Salesforce opportunity line item id. Raises: ValidationError: If any required parameter is not present in enrollment. @@ -410,6 +417,7 @@ def _get_enrollment_data(self, enrollment): course_run_key = enrollment.get('course_run_key') discount_percentage = enrollment.get('discount_percentage') sales_force_id = enrollment.get('sales_force_id') + salesforce_opportunity_line_item = enrollment.get('salesforce_opportunity_line_item') mode = enrollment.get('mode') if not (lms_user_id and learner_username and learner_email and course_run_key and mode): enrollment_parameters_state = [ @@ -431,7 +439,8 @@ def _get_enrollment_data(self, enrollment): if discount_percentage is not None: if not isinstance(discount_percentage, float) or (discount_percentage < 0.0 or discount_percentage > 100.0): raise ValidationError('Discount percentage should be a float from 0 to 100.') - return lms_user_id, learner_username, learner_email, course_run_key, mode, discount_percentage, sales_force_id + return lms_user_id, learner_username, learner_email, course_run_key, mode, discount_percentage,\ + sales_force_id, salesforce_opportunity_line_item def _get_learner_user(self, lms_user_id, learner_username, learner_email): """ @@ -447,7 +456,8 @@ def _get_learner_user(self, lms_user_id, learner_username, learner_email): return learner_user - def _get_or_create_discount_offer(self, enterprise_customer_name, enterprise_customer_uuid, sales_force_id): + def _get_or_create_discount_offer( + self, enterprise_customer_name, enterprise_customer_uuid, sales_force_id, salesforce_opportunity_line_item): """ Get or Create 100% discount offer for `Manual Enrollment Order`. """ @@ -481,6 +491,11 @@ def _get_or_create_discount_offer(self, enterprise_customer_name, enterprise_cus offer.sales_force_id = sales_force_id offer.save() + if salesforce_opportunity_line_item and\ + offer.salesforce_opportunity_line_item != salesforce_opportunity_line_item: + offer.salesforce_opportunity_line_item = salesforce_opportunity_line_item + offer.save() + return offer def handle_successful_order(self, order, request=None): # pylint: disable=arguments-differ diff --git a/ecommerce/extensions/catalogue/migrations/0055_sf_opp_line_item_ent_attr.py b/ecommerce/extensions/catalogue/migrations/0055_sf_opp_line_item_ent_attr.py new file mode 100644 index 00000000000..588e94ed7e2 --- /dev/null +++ b/ecommerce/extensions/catalogue/migrations/0055_sf_opp_line_item_ent_attr.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- + + +from django.db import migrations +from oscar.core.loading import get_model + +from ecommerce.core.constants import COUPON_PRODUCT_CLASS_NAME + +ProductAttribute = get_model("catalogue", "ProductAttribute") +ProductClass = get_model("catalogue", "ProductClass") + +SF_LINE_ITEM_ATTRIBUTE_CODE = 'salesforce_opportunity_line_item' + + +def create_sf_opp_line_item_attribute(apps, schema_editor): + """Create enterprise coupon salesforce_opportunity_line_item product attribute.""" + ProductAttribute.skip_history_when_saving = True + coupon = ProductClass.objects.get(name=COUPON_PRODUCT_CLASS_NAME) + pa = ProductAttribute( + product_class=coupon, + name='Salesforce Opportunity Line Item', + code=SF_LINE_ITEM_ATTRIBUTE_CODE, + type='text', + required=True, + ) + pa.save() + + +def remove_sf_opp_line_item_attribute(apps, schema_editor): + """Remove enterprise coupon salesforce_opportunity_line_item product attribute.""" + coupon = ProductClass.objects.get(name=COUPON_PRODUCT_CLASS_NAME) + ProductAttribute.skip_history_when_saving = True + ProductAttribute.objects.get(product_class=coupon, code=SF_LINE_ITEM_ATTRIBUTE_CODE).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0054_add_variant_id_product_attr') + ] + operations = [ + migrations.RunPython( + create_sf_opp_line_item_attribute, + remove_sf_opp_line_item_attribute, + ), + ] diff --git a/ecommerce/extensions/catalogue/tests/test_models.py b/ecommerce/extensions/catalogue/tests/test_models.py index c9720ddc546..053abd39782 100644 --- a/ecommerce/extensions/catalogue/tests/test_models.py +++ b/ecommerce/extensions/catalogue/tests/test_models.py @@ -19,7 +19,8 @@ class ProductTests(CouponMixin, DiscoveryTestMixin, TestCase): COUPON_PRODUCT_TITLE = 'Some test title.' - def _create_coupon_product_with_attributes(self, note='note', notify_email=None, sales_force_id=None): + def _create_coupon_product_with_attributes( + self, note='note', notify_email=None, sales_force_id=None, salesforce_opportunity_line_item=None): """Helper method that creates a coupon product with note, notify_email and sales_force_id attributes.""" coupon_product = factories.ProductFactory( title=self.COUPON_PRODUCT_TITLE, @@ -35,6 +36,8 @@ def _create_coupon_product_with_attributes(self, note='note', notify_email=None, coupon_product.attr.notify_email = notify_email if sales_force_id: coupon_product.attr.sales_force_id = sales_force_id + if salesforce_opportunity_line_item: + coupon_product.attr.salesforce_opportunity_line_item = salesforce_opportunity_line_item coupon_product.save() return coupon_product @@ -88,6 +91,13 @@ def test_create_product_with_sales_force_id(self): coupon = self._create_coupon_product_with_attributes(sales_force_id=sales_force_id) self.assertEqual(coupon.attr.sales_force_id, sales_force_id) + def test_create_product_with_salesforce_opportunity_line_item(self): + """Verify creating a product with salesforce_opportunity_line_item.""" + salesforce_opportunity_line_item = 'salesforceopportunitylineitem123' + coupon = self._create_coupon_product_with_attributes( + salesforce_opportunity_line_item=salesforce_opportunity_line_item) + self.assertEqual(coupon.attr.salesforce_opportunity_line_item, salesforce_opportunity_line_item) + @ddt.data(1, {'some': 'dict'}, ['array']) def test_incorrect_note_value_raises_exception(self, note): """Verify creating product with invalid note type raises ValidationError.""" diff --git a/ecommerce/extensions/catalogue/tests/test_utils.py b/ecommerce/extensions/catalogue/tests/test_utils.py index 133d07c9536..94a7fe6505b 100644 --- a/ecommerce/extensions/catalogue/tests/test_utils.py +++ b/ecommerce/extensions/catalogue/tests/test_utils.py @@ -58,7 +58,8 @@ def test_generate_sku_for_course_seat(self, course_id): certificate_type = 'audit' product = course.create_or_update_seat(certificate_type, False, 0) - _hash = '{} {} {} {} {}'.format(certificate_type, course_id, 'False', '', self.partner.id).encode('utf-8') + _hash = '{} {} {} {} {} {}'.format(certificate_type, course_id, 'False', '', product.id, + self.partner.id).encode('utf-8') _hash = md5(_hash.lower()).hexdigest()[-7:] # verify that generated sku has partner 'short_code' as prefix expected = _hash.upper() @@ -146,7 +147,7 @@ def setUp(self): self.catalog = Catalog.objects.create(partner=self.partner) def create_custom_coupon(self, benefit_value=100, code='', max_uses=None, note=None, quantity=1, - title='Tešt Čoupon', sales_force_id=None): + title='Tešt Čoupon', sales_force_id=None, salesforce_opportunity_line_item=None): """Create a custom test coupon product.""" return create_coupon_product( @@ -172,7 +173,8 @@ def create_custom_coupon(self, benefit_value=100, code='', max_uses=None, note=N voucher_type=Voucher.ONCE_PER_CUSTOMER, program_uuid=None, site=self.site, - sales_force_id=sales_force_id + sales_force_id=sales_force_id, + salesforce_opportunity_line_item=salesforce_opportunity_line_item ) def test_custom_code_integrity_error(self): @@ -224,3 +226,12 @@ def test_coupon_sales_force_id(self): note_coupon = self.create_custom_coupon(sales_force_id=sales_force_id, title=title) self.assertEqual(note_coupon.attr.sales_force_id, sales_force_id) self.assertEqual(note_coupon.title, title) + + def test_coupon_salesforce_opportunity_line_item(self): + """Test creating a coupon with sales force opprtunity id.""" + salesforce_opportunity_line_item = 'salesforcelineItem123' + title = 'Coupon' + note_coupon = self.create_custom_coupon( + salesforce_opportunity_line_item=salesforce_opportunity_line_item, title=title) + self.assertEqual(note_coupon.attr.salesforce_opportunity_line_item, salesforce_opportunity_line_item) + self.assertEqual(note_coupon.title, title) diff --git a/ecommerce/extensions/catalogue/utils.py b/ecommerce/extensions/catalogue/utils.py index 09b864747b1..fceb3a4ba03 100644 --- a/ecommerce/extensions/catalogue/utils.py +++ b/ecommerce/extensions/catalogue/utils.py @@ -43,7 +43,8 @@ def create_coupon_product( course_catalog, program_uuid, site, - sales_force_id + salesforce_opportunity_line_item, + sales_force_id=None, ): """ Creates a coupon product and a stock record for it. @@ -72,6 +73,7 @@ def create_coupon_product( program_uuid (str): Program UUID for the Coupon site (site): Site for which the Coupon is created. sales_force_id (str): Sales Force Opprtunity ID + salesforce_opportunity_line_item (str): Sales Force Opportunity Line Item ID Returns: A coupon Product object. @@ -112,7 +114,8 @@ def create_coupon_product( raise attach_vouchers_to_coupon_product(coupon_product, vouchers, note, enterprise_id=enterprise_customer, - sales_force_id=sales_force_id) + sales_force_id=sales_force_id, + salesforce_opportunity_line_item=salesforce_opportunity_line_item) return coupon_product @@ -164,7 +167,7 @@ def attach_or_update_contract_metadata_on_coupon(coupon, **update_kwargs): def attach_vouchers_to_coupon_product(coupon_product, vouchers, note, notify_email=None, enterprise_id=None, - sales_force_id=None): + sales_force_id=None, salesforce_opportunity_line_item=None): coupon_vouchers, __ = CouponVouchers.objects.get_or_create(coupon=coupon_product) coupon_vouchers.vouchers.add(*vouchers) coupon_product.attr.coupon_vouchers = coupon_vouchers @@ -173,6 +176,8 @@ def attach_vouchers_to_coupon_product(coupon_product, vouchers, note, notify_ema coupon_product.attr.notify_email = notify_email if sales_force_id: coupon_product.attr.sales_force_id = sales_force_id + if salesforce_opportunity_line_item: + coupon_product.attr.salesforce_opportunity_line_item = salesforce_opportunity_line_item if enterprise_id: coupon_product.attr.enterprise_customer_uuid = enterprise_id coupon_product.save() @@ -204,6 +209,7 @@ def generate_sku(product, partner): str(product.attr.course_key), str(product.attr.id_verification_required), getattr(product.attr, 'credit_provider', ''), + str(product.id), str(partner.id) )).encode('utf-8') elif product.is_course_entitlement_product: diff --git a/ecommerce/extensions/checkout/tests/test_mixins.py b/ecommerce/extensions/checkout/tests/test_mixins.py index a2477027e0f..a34f38c24ac 100644 --- a/ecommerce/extensions/checkout/tests/test_mixins.py +++ b/ecommerce/extensions/checkout/tests/test_mixins.py @@ -399,7 +399,7 @@ def test_handle_successful_order_segment_error(self, mock_track): # ensure that analytics.track was called, but the exception was caught self.assertTrue(mock_track.called) # ensure we logged a warning. - self.assertTrue(mock_log_exc.called_with("Failed to emit tracking event upon order placement.")) + mock_log_exc.assert_called_with("Failed to emit tracking event upon order completion.") def test_handle_successful_async_order(self, __): """ diff --git a/ecommerce/extensions/dashboard/catalogue/__init__.py b/ecommerce/extensions/dashboard/catalogue/__init__.py new file mode 100644 index 00000000000..c2871c11d6f --- /dev/null +++ b/ecommerce/extensions/dashboard/catalogue/__init__.py @@ -0,0 +1 @@ +default_app_config = 'ecommerce.extensions.dashboard.catalogue.apps.CatalogueDashboardConfig' diff --git a/ecommerce/extensions/dashboard/catalogue/apps.py b/ecommerce/extensions/dashboard/catalogue/apps.py new file mode 100644 index 00000000000..b32b6937121 --- /dev/null +++ b/ecommerce/extensions/dashboard/catalogue/apps.py @@ -0,0 +1,5 @@ +from oscar.apps.dashboard.catalogue import apps + + +class CatalogueDashboardConfig(apps.CatalogueDashboardConfig): + name = 'ecommerce.extensions.dashboard.catalogue' diff --git a/ecommerce/extensions/dashboard/catalogue/forms.py b/ecommerce/extensions/dashboard/catalogue/forms.py new file mode 100644 index 00000000000..9fcacc439df --- /dev/null +++ b/ecommerce/extensions/dashboard/catalogue/forms.py @@ -0,0 +1,9 @@ +from oscar.apps.dashboard.catalogue.forms import ProductForm as BaseProductForm + + +class ProductForm(BaseProductForm): + class Meta(BaseProductForm.Meta): + fields = [ + 'title', 'course', 'expires', 'upc', 'description', + 'is_public', 'is_discountable', 'structure' + ] diff --git a/ecommerce/extensions/executive_education_2u/constants.py b/ecommerce/extensions/executive_education_2u/constants.py index 733e8892dd7..5d62041d86a 100644 --- a/ecommerce/extensions/executive_education_2u/constants.py +++ b/ecommerce/extensions/executive_education_2u/constants.py @@ -2,6 +2,7 @@ class ExecutiveEducation2UCheckoutFailureReason: NO_OFFER_AVAILABLE = 'no_offer_available' NO_OFFER_WITH_ENOUGH_BALANCE = 'no_offer_with_enough_balance' NO_OFFER_WITH_ENOUGH_USER_BALANCE = 'no_offer_with_enough_user_balance' + NO_OFFER_WITH_REMAINING_APPLICATIONS = 'no_offer_with_remaining_applications' SYSTEM_ERROR = 'system_error' diff --git a/ecommerce/extensions/executive_education_2u/mixins.py b/ecommerce/extensions/executive_education_2u/mixins.py index 5ee55b2efc9..a712fb81ef6 100644 --- a/ecommerce/extensions/executive_education_2u/mixins.py +++ b/ecommerce/extensions/executive_education_2u/mixins.py @@ -17,6 +17,7 @@ def place_free_order( address, user_details, terms_accepted_at, + data_share_consent, request=None, ): # pylint: disable=arguments-differ """ @@ -44,11 +45,12 @@ def place_free_order( order_metadata['number'], basket.id, ) - + dsc = str(data_share_consent).lower() fulfillment_details = json.dumps({ 'address': address, 'user_details': user_details, 'terms_accepted_at': terms_accepted_at, + 'data_share_consent': dsc, }) # Place an order. If order placement succeeds, the order is committed diff --git a/ecommerce/extensions/executive_education_2u/serializers.py b/ecommerce/extensions/executive_education_2u/serializers.py index a4e2d9773d2..43692ff05ae 100644 --- a/ecommerce/extensions/executive_education_2u/serializers.py +++ b/ecommerce/extensions/executive_education_2u/serializers.py @@ -26,3 +26,4 @@ class CheckoutActionSerializer(serializers.Serializer): # pylint: disable=abstr address = AddressSerializer(required=False) user_details = UserDetailsSerializer() terms_accepted_at = serializers.CharField() + data_share_consent = serializers.BooleanField(required=False) diff --git a/ecommerce/extensions/executive_education_2u/tests/test_mixins.py b/ecommerce/extensions/executive_education_2u/tests/test_mixins.py index d4980311114..ca1da4afb1e 100644 --- a/ecommerce/extensions/executive_education_2u/tests/test_mixins.py +++ b/ecommerce/extensions/executive_education_2u/tests/test_mixins.py @@ -44,6 +44,8 @@ def setUp(self): 'mobile_phone': '1234567890' } self.mock_terms_accepted_at = '2022-08-05T15:28:46.493Z', + self.mock_data_share_consent = True + self.expected_data_share_consent = 'true' # Ensure that the basket attribute type exists for these tests self.basket_attribute_type, _ = BasketAttributeType.objects.get_or_create( @@ -57,12 +59,14 @@ def test_order_note_created(self): 'address': self.mock_address, 'user_details': self.mock_user_details, 'terms_accepted_at': self.mock_terms_accepted_at, + 'data_share_consent': self.expected_data_share_consent, }) order = ExecutiveEducation2UOrderPlacementMixin().place_free_order( basket, self.mock_address, self.mock_user_details, self.mock_terms_accepted_at, + self.mock_data_share_consent, ) self.assertEqual(basket.status, Basket.SUBMITTED) @@ -77,4 +81,5 @@ def test_non_free_basket_order(self): self.mock_address, self.mock_user_details, self.mock_terms_accepted_at, + self.mock_data_share_consent, ) diff --git a/ecommerce/extensions/executive_education_2u/tests/test_views.py b/ecommerce/extensions/executive_education_2u/tests/test_views.py index 28d39e81e38..8d2215e7e5d 100644 --- a/ecommerce/extensions/executive_education_2u/tests/test_views.py +++ b/ecommerce/extensions/executive_education_2u/tests/test_views.py @@ -14,6 +14,8 @@ from ecommerce.extensions.executive_education_2u.constants import ExecutiveEducation2UCheckoutFailureReason from ecommerce.extensions.executive_education_2u.utils import get_previous_order_for_user from ecommerce.extensions.fulfillment.status import ORDER +from ecommerce.extensions.refund.status import REFUND +from ecommerce.extensions.refund.tests.factories import RefundFactory from ecommerce.extensions.test import factories from ecommerce.tests.mixins import JwtMixin from ecommerce.tests.testcases import TestCase @@ -87,7 +89,7 @@ def _create_product(self, is_exec_ed_2u_product=True): product.attr.save() return product - def _create_enterprise_offer(self, max_discount=None, max_user_discount=None): + def _create_enterprise_offer(self, max_discount=None, max_user_discount=None, **kwargs): condition = factories.EnterpriseCustomerConditionFactory( enterprise_customer_uuid=self.enterprise_customer_uuid ) @@ -98,7 +100,8 @@ def _create_enterprise_offer(self, max_discount=None, max_user_discount=None): condition=condition, offer_type=ConditionalOffer.SITE, max_discount=max_discount, - max_user_discount=max_user_discount + max_user_discount=max_user_discount, + **kwargs, ) return offer @@ -130,6 +133,57 @@ def test_begin_checkout_has_previous_order_redirect_to_receipt_page(self): ) self.assertEqual(response.headers['Location'], expected_redirect_url) + def test_begin_checkout_has_previous_order_refund_error_redirect_to_receipt_page(self): + product = self._create_product(is_exec_ed_2u_product=True) + sku = product.stockrecords.first().partner_sku + order = OrderFactory(user=self.user) + OrderLineFactory(order=order, product=product, partner_sku=sku) + RefundFactory(order=order, user=self.user, status=REFUND.PAYMENT_REFUND_ERROR) + + response = self.client.get(self.checkout_path, {'sku': sku}) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + expected_redirect_url = get_receipt_page_url( + response.request, + order_number=order.number, + site_configuration=order.site.siteconfiguration, + disable_back_button=False + ) + self.assertEqual(response.headers['Location'], expected_redirect_url) + + @mock.patch('ecommerce.enterprise.conditions.catalog_contains_course_runs') + @mock.patch('ecommerce.enterprise.conditions.get_course_info_from_catalog') + @mock.patch('ecommerce.extensions.executive_education_2u.views.get_learner_portal_url') + def test_begin_checkout_has_previous_refunded_order_redirect_to_lp( + self, + mock_get_learner_portal_url, + mock_get_course_info_from_catalog, + mock_catalog_contains_course_runs + ): + mock_get_learner_portal_url.return_value = self.learner_portal_url + product = self._create_product(is_exec_ed_2u_product=True) + sku = product.stockrecords.first().partner_sku + self._create_enterprise_offer() + + order = OrderFactory(user=self.user) + OrderLineFactory(order=order, product=product, partner_sku=sku) + RefundFactory(order=order, user=self.user, status=REFUND.COMPLETE) + + mock_get_course_info_from_catalog.return_value = { + 'key': product.attr.UUID + } + mock_catalog_contains_course_runs.return_value = True + + response = self.client.get(self.checkout_path, {'sku': sku}) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + expected_query_params = { + 'course_uuid': product.attr.UUID, + 'sku': sku + } + expected_redirect_url = f'{self.learner_portal_url}/executive-education-2u?{urlencode(expected_query_params)}' + self.assertEqual(response.headers['Location'], expected_redirect_url) + @mock.patch('ecommerce.enterprise.conditions.catalog_contains_course_runs') @mock.patch('ecommerce.enterprise.conditions.get_course_info_from_catalog') @mock.patch('ecommerce.extensions.executive_education_2u.views.get_learner_portal_url') @@ -253,6 +307,41 @@ def test_begin_checkout_no_offer_with_enough_user_balance( expected_redirect_url = f'{self.learner_portal_url}/executive-education-2u?{urlencode(expected_query_params)}' self.assertEqual(response.headers['Location'], expected_redirect_url) + @mock.patch('ecommerce.extensions.executive_education_2u.views.fetch_enterprise_catalogs_for_content_items') + @mock.patch('ecommerce.extensions.executive_education_2u.views.get_course_info_from_catalog') + @mock.patch('ecommerce.extensions.executive_education_2u.views.get_learner_portal_url') + def test_begin_checkout_no_offer_with_remaining_applications( + self, + mock_get_learner_portal_url, + mock_get_course_info_from_catalog, + mock_fetch_enterprise_catalogs_for_content_items + ): + mock_get_learner_portal_url.return_value = self.learner_portal_url + offer = self._create_enterprise_offer(max_user_applications=1) + mock_fetch_enterprise_catalogs_for_content_items.return_value = [ + offer.condition.enterprise_customer_catalog_uuid + ] + product = self._create_product(is_exec_ed_2u_product=True) + sku = product.stockrecords.first().partner_sku + mock_get_course_info_from_catalog.return_value = { + 'key': product.attr.UUID + } + + # Create order discounts + order = OrderFactory(user=self.user, status=ORDER.COMPLETE) + OrderDiscountFactory(order=order, offer_id=offer.id, frequency=1) + + response = self.client.get(self.checkout_path, {'sku': sku}) + self.assertEqual(response.status_code, status.HTTP_302_FOUND) + + expected_query_params = { + 'course_uuid': product.attr.UUID, + 'sku': sku, + 'failure_reason': ExecutiveEducation2UCheckoutFailureReason.NO_OFFER_WITH_REMAINING_APPLICATIONS + } + expected_redirect_url = f'{self.learner_portal_url}/executive-education-2u?{urlencode(expected_query_params)}' + self.assertEqual(response.headers['Location'], expected_redirect_url) + @mock.patch('ecommerce.extensions.executive_education_2u.views.get_course_info_from_catalog') @mock.patch('ecommerce.extensions.executive_education_2u.views.get_learner_portal_url') def test_begin_checkout_system_error( @@ -295,6 +384,7 @@ def _create_finish_checkout_payload(self, sku): 'mobile_phone': '1234567890' }, 'terms_accepted_at': '2022-08-05T15:28:46.493Z', + 'data_share_consent': True, } def _create_basket(self, product, has_offer=False): diff --git a/ecommerce/extensions/executive_education_2u/utils.py b/ecommerce/extensions/executive_education_2u/utils.py index b1a122dde9f..d55500f818d 100644 --- a/ecommerce/extensions/executive_education_2u/utils.py +++ b/ecommerce/extensions/executive_education_2u/utils.py @@ -8,6 +8,7 @@ from ecommerce.courses.constants import CertificateType from ecommerce.enterprise.api import get_enterprise_id_for_user from ecommerce.enterprise.utils import get_enterprise_customer +from ecommerce.extensions.refund.status import REFUND Product = get_model('catalogue', 'Product') Order = get_model('order', 'Order') @@ -32,8 +33,14 @@ def get_executive_education_2u_product(partner, sku): def get_previous_order_for_user(user, product): - # TODO: figure out if users can place orders multiple times - return Order.objects.filter(user=user, lines__product=product).first() + """ + Find previous non-refunded order for product from user. + """ + return Order.objects \ + .filter(user=user, lines__product=product) \ + .prefetch_related('refunds') \ + .filter(Q(refunds__isnull=True) | ~Q(refunds__status=REFUND.COMPLETE)) \ + .first() def get_learner_portal_url(request): diff --git a/ecommerce/extensions/executive_education_2u/views.py b/ecommerce/extensions/executive_education_2u/views.py index 91b7f0f2338..1421e890639 100644 --- a/ecommerce/extensions/executive_education_2u/views.py +++ b/ecommerce/extensions/executive_education_2u/views.py @@ -116,17 +116,51 @@ def _prepare_basket(self, request, product): def _get_checkout_failure_reason(self, request, basket, product): try: + # logger 1 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] checkout_failure_reason 1: Checking if user [%s] has ' + 'purchased product [%s] previously from basket [%s].', + request.user.id, product, basket) enterprise_id = get_enterprise_id_for_user(request.site, request.user) course_info = get_course_info_from_catalog(request.site, product) course_key = course_info['key'] + # logger 2 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] checkout_failure_reason step 2: User [%s] is attempting ' + 'to checkout for course [%s] with enterprise id [%s]', + request.user.id, + course_key, + enterprise_id, + ) catalog_list = fetch_enterprise_catalogs_for_content_items( request.site, course_key, enterprise_id ) + # logger 3 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] checkout_failure_reason step 3: User [%s] is attempting ' + 'to checkout for course [%s] with enterprise id [%s] and catalog list [%s]', + request.user.id, + course_key, + enterprise_id, + catalog_list, + ) enterprise_offers = get_enterprise_offers_for_catalogs(enterprise_id, catalog_list) + + # logger 4 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] checkout_failure_reason step 4: User [%s] is attempting ' + 'to checkout for course [%s] with enterprise id [%s] and catalog list [%s] and enterprise ' + 'offers [%s]', + request.user.id, + course_key, + enterprise_id, + catalog_list, + enterprise_offers, + ) if not enterprise_offers: return ExecutiveEducation2UCheckoutFailureReason.NO_OFFER_AVAILABLE @@ -136,6 +170,20 @@ def _get_checkout_failure_reason(self, request, basket, product): basket, offer ) ] + + # logger 5 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] checkout_failure_reason step 5: User [%s] is attempting ' + 'to checkout for course [%s] with enterprise id [%s] and catalog list [%s] and enterprise ' + 'offers [%s] and offers with remaining balance [%s]', + request.user.id, + course_key, + enterprise_id, + catalog_list, + enterprise_offers, + offers_with_remaining_balance, + ) + if not offers_with_remaining_balance: return ExecutiveEducation2UCheckoutFailureReason.NO_OFFER_WITH_ENOUGH_BALANCE @@ -148,6 +196,14 @@ def _get_checkout_failure_reason(self, request, basket, product): if not offers_with_remaining_user_balance: return ExecutiveEducation2UCheckoutFailureReason.NO_OFFER_WITH_ENOUGH_USER_BALANCE + + offers_with_remaining_enrollment_space = [ + offer for offer in offers_with_remaining_user_balance + if offer.is_available(user=request.user) + ] + + if not offers_with_remaining_enrollment_space: + return ExecutiveEducation2UCheckoutFailureReason.NO_OFFER_WITH_REMAINING_APPLICATIONS except Exception as ex: # pylint: disable=broad-except logger.exception(ex) @@ -171,8 +227,16 @@ def begin_checkout(self, request): return HttpResponseNotFound(f'No Executive Education (2U) product found for SKU {sku}.') try: + # logger 1 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] User [%s] is attempting to checkout for product [%s] with sku [%s]', + request.user.id, + product, + sku, + ) # Create basket and see if total cost is $0 basket = self._prepare_basket(request, product) + course_uuid = getattr(product.attr, 'UUID', '') # Create the query params that will be used by the learner portal @@ -181,19 +245,63 @@ def begin_checkout(self, request): 'sku': sku } + # logger 2 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] User [%s] is attempting to checkout ' + 'basket [%s] (price: [%s]) for product [%s] with sku [%s] and course_uuid [%s] with ' + 'query params [%s]', + request.user.id, + basket.id, + basket.total_excl_tax, + product, + sku, + course_uuid, + query_params, + ) + referer = request.headers.get('referer', '') + + # logger 3 for debugging ent-6954 + logger.info('[ExecutiveEducation2UViewSet] HTTP Referer: %s', referer) + if referer: query_params.update({'http_referer': referer}) failure_reason = None # Users cannot purchase Exec Ed 2U products directly if basket.total_excl_tax != 0: + # logger 4 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] User [%s] is attempting to checkout for product [%s] with ' + 'sku [%s] and course_uuid [%s] with query params [%s] and basket total [%s]', + request.user.id, + product, + sku, + course_uuid, + query_params, + basket.total_excl_tax, + ) failure_reason = self._get_checkout_failure_reason(request, basket, product) + + # logger 5 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] User [%s] encountered a failure_reason: %s', + request.user.id, + failure_reason, + ) + query_params.update({ 'failure_reason': failure_reason }) basket.flush() + # logger 6 for debugging ent-6954 + logger.info( + '[ExecutiveEducation2UViewSet] flushed basket [%s] for user [%s]', + basket, + request.user.id, + ) + # Redirect users to learner portals for terms & policies or error display learner_portal_url = get_learner_portal_url(request) redirect_url = f'{learner_portal_url}/executive-education-2u?{urlencode(query_params)}' @@ -255,6 +363,7 @@ def finish_checkout(self, request): address=request.data.get('address', {}), user_details={**request.data['user_details'], 'email': request.user.email}, terms_accepted_at=request.data['terms_accepted_at'], + data_share_consent=request.data.get('data_share_consent', None), request=request ) diff --git a/ecommerce/extensions/fulfillment/modules.py b/ecommerce/extensions/fulfillment/modules.py index c746533478a..33ebe0f0e5f 100644 --- a/ecommerce/extensions/fulfillment/modules.py +++ b/ecommerce/extensions/fulfillment/modules.py @@ -29,10 +29,11 @@ ) from ecommerce.core.url_utils import get_lms_enrollment_api_url, get_lms_entitlement_api_url from ecommerce.courses.models import Course -from ecommerce.courses.utils import mode_for_product +from ecommerce.courses.utils import get_course_info_from_catalog, mode_for_product from ecommerce.enterprise.conditions import BasketAttributeType from ecommerce.enterprise.mixins import EnterpriseDiscountMixin from ecommerce.enterprise.utils import ( + create_enterprise_customer_user_consent, get_enterprise_customer_uuid_from_voucher, get_or_create_enterprise_customer_user ) @@ -949,8 +950,9 @@ def _create_enterprise_allocation_payload( # This will be the offer that was applied. We will let an error be thrown if this doesn't exist. discount = order.discounts.first() enterprise_customer_uuid = str(discount.offer.condition.enterprise_customer_uuid) + data_share_consent = fulfillment_details.get('data_share_consent', None) - return { + payload = { 'payment_reference': order.number, 'enterprise_customer_uuid': enterprise_customer_uuid, 'currency': currency, @@ -966,8 +968,30 @@ def _create_enterprise_allocation_payload( ], **fulfillment_details.get('address', {}), **fulfillment_details.get('user_details', {}), - 'terms_accepted_at': fulfillment_details.get('terms_accepted_at', '') + 'terms_accepted_at': fulfillment_details.get('terms_accepted_at', ''), } + if data_share_consent: + payload['data_share_consent'] = data_share_consent + + return payload + + def _create_enterprise_customer_user_consent( + self, + order, + line, + fulfillment_details + ): + data_share_consent = fulfillment_details.get('data_share_consent', None) + course_info = get_course_info_from_catalog(order.site, line.product) + if data_share_consent and course_info: + discount = order.discounts.first() + enterprise_customer_uuid = str(discount.offer.condition.enterprise_customer_uuid) + create_enterprise_customer_user_consent( + site=order.site, + enterprise_customer_uuid=enterprise_customer_uuid, + course_id=course_info['key'], + username=order.user.username + ) def _get_fulfillment_details(self, order): fulfillment_details_note = order.notes.filter(note_type='Fulfillment Details').first() @@ -1020,6 +1044,11 @@ def fulfill_product(self, order, lines, email_opt_in=False): ) try: + self._create_enterprise_customer_user_consent( + order=order, + line=line, + fulfillment_details=fulfillment_details + ) self.get_smarter_client.create_enterprise_allocation(**allocation_payload) except Exception as ex: # pylint: disable=broad-except reason = '' @@ -1029,8 +1058,9 @@ def fulfill_product(self, order, lines, email_opt_in=False): pass logger.exception( - 'Fulfillment of line [%d] on order [%s] failed. Reason: %s.', - line.id, order.number, reason + '[ExecutiveEducation2UFulfillmentModule] Fulfillment of line [%d] on ' + 'order [%s] failed. Reason: %s. Fulfillment details: %s.', + line.id, order.number, reason, fulfillment_details, ) line.set_status(LINE.FULFILLMENT_SERVER_ERROR) else: diff --git a/ecommerce/extensions/fulfillment/tests/test_modules.py b/ecommerce/extensions/fulfillment/tests/test_modules.py index 0d1b5dc2a2a..60be1a7c036 100644 --- a/ecommerce/extensions/fulfillment/tests/test_modules.py +++ b/ecommerce/extensions/fulfillment/tests/test_modules.py @@ -1253,6 +1253,7 @@ def setUp(self): 'mobile_phone': '+12015551234', }, 'terms_accepted_at': '2022-07-25T10:29:56Z', + 'data_share_consent': True }) self.mock_settings = { @@ -1304,37 +1305,59 @@ def test_fulfill_product_maformed_fulfillment_details(self, mock_logger): self.exec_ed_2u_entitlement_line.order.number ) + @mock.patch('ecommerce.extensions.fulfillment.modules.create_enterprise_customer_user_consent') + @mock.patch('ecommerce.extensions.fulfillment.modules.get_course_info_from_catalog') @mock.patch('ecommerce.extensions.fulfillment.modules.GetSmarterEnterpriseApiClient') - def test_fulfill_product_success(self, mock_geag_client): + def test_fulfill_product_success( + self, + mock_geag_client, + mock_get_course_info_from_catalog, + mock_create_enterprise_customer_user_consent + ): with self.settings(**self.mock_settings): mock_create_enterprise_allocation = mock.MagicMock() mock_geag_client.return_value = mock.MagicMock( create_enterprise_allocation=mock_create_enterprise_allocation ) + mock_get_course_info_from_catalog.return_value = { + 'key': 'test_course_key1' + } self.order.notes.create(message=self.fulfillment_details, note_type='Fulfillment Details') ExecutiveEducation2UFulfillmentModule().fulfill_product( self.order, [self.exec_ed_2u_entitlement_line, self.exec_ed_2u_entitlement_line_2] ) self.assertEqual(mock_create_enterprise_allocation.call_count, 2) + self.assertEqual(mock_create_enterprise_customer_user_consent.call_count, 2) self.assertEqual(self.exec_ed_2u_entitlement_line.status, LINE.COMPLETE) self.assertEqual(self.exec_ed_2u_entitlement_line_2.status, LINE.COMPLETE) self.assertFalse(self.order.notes.exists()) + @mock.patch('ecommerce.extensions.fulfillment.modules.create_enterprise_customer_user_consent') + @mock.patch('ecommerce.extensions.fulfillment.modules.get_course_info_from_catalog') @mock.patch('ecommerce.extensions.fulfillment.modules.GetSmarterEnterpriseApiClient') - def test_fulfill_product_error(self, mock_geag_client): + def test_fulfill_product_error( + self, + mock_geag_client, + mock_get_course_info_from_catalog, + mock_create_enterprise_customer_user_consent + ): with self.settings(**self.mock_settings): mock_create_enterprise_allocation = mock.MagicMock() mock_create_enterprise_allocation.side_effect = [None, Exception("Uh oh.")] mock_geag_client.return_value = mock.MagicMock( create_enterprise_allocation=mock_create_enterprise_allocation ) + mock_get_course_info_from_catalog.return_value = { + 'key': 'test_course_key1' + } self.order.notes.create(message=self.fulfillment_details, note_type='Fulfillment Details') ExecutiveEducation2UFulfillmentModule().fulfill_product( self.order, [self.exec_ed_2u_entitlement_line, self.exec_ed_2u_entitlement_line_2] ) self.assertEqual(mock_create_enterprise_allocation.call_count, 2) + self.assertEqual(mock_create_enterprise_customer_user_consent.call_count, 2) self.assertEqual(self.exec_ed_2u_entitlement_line.status, LINE.COMPLETE) self.assertEqual(self.exec_ed_2u_entitlement_line_2.status, LINE.FULFILLMENT_SERVER_ERROR) self.assertTrue(self.order.notes.exists()) diff --git a/ecommerce/extensions/iap/admin.py b/ecommerce/extensions/iap/admin.py index e69de29bb2d..728f061d441 100644 --- a/ecommerce/extensions/iap/admin.py +++ b/ecommerce/extensions/iap/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from solo.admin import SingletonModelAdmin + +from ecommerce.extensions.iap.models import IAPProcessorConfiguration, PaymentProcessorResponseExtension + +admin.site.register(IAPProcessorConfiguration, SingletonModelAdmin) + + +@admin.register(PaymentProcessorResponseExtension) +class PaymentProcessorResponseExtensionAdmin(admin.ModelAdmin): + list_display = ('original_transaction_id', 'processor_response') diff --git a/ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer b/ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer new file mode 100644 index 00000000000..228bfa39cbd Binary files /dev/null and b/ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer differ diff --git a/ecommerce/extensions/iap/api/v1/constants.py b/ecommerce/extensions/iap/api/v1/constants.py index 20aec9a6603..4ba77782b25 100644 --- a/ecommerce/extensions/iap/api/v1/constants.py +++ b/ecommerce/extensions/iap/api/v1/constants.py @@ -1,20 +1,42 @@ """ Constants for iap extension apis v1 """ COURSE_ADDED_TO_BASKET = "Course added to the basket successfully" -COURSE_ALREADY_PAID_ON_DEVICE = "The course has already been paid for on this device by the associated Apple ID." +COURSE_ALREADY_PAID_ON_DEVICE = "The course upgrade has already been paid for by the user." +DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME = "disable_redundant_payment_check_for_mobile" ERROR_ALREADY_PURCHASED = "You have already purchased these products" ERROR_BASKET_NOT_FOUND = "Basket [{}] not found." ERROR_BASKET_ID_NOT_PROVIDED = "Basket id is not provided" ERROR_DURING_ORDER_CREATION = "An error occurred during order creation." ERROR_DURING_PAYMENT_HANDLING = "An error occurred during payment handling." +ERROR_ORDER_NOT_FOUND_FOR_REFUND = "Could not find any order to refund for [%s] by processor [%s]" +ERROR_REFUND_NOT_COMPLETED = "Could not complete refund for user [%s] in course [%s] by processor [%s]" +ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND = "Could not find any transaction to refund for [%s] by processor [%s]" ERROR_DURING_POST_ORDER_OP = "An error occurred during post order operations." -ERROR_WHILE_OBTAINING_BASKET_FOR_USER = "An unexpected exception occurred while obtaining basket for user [{}]." -LOGGER_BASKET_NOT_FOUND = "Basket [%s] not found." -LOGGER_PAYMENT_APPROVED = "Payment [%s] approved by payer [%s]" -LOGGER_PAYMENT_FAILED_FOR_BASKET = "Attempts to handle payment for basket [%d] failed." +GOOGLE_PUBLISHER_API_SCOPE = "https://www.googleapis.com/auth/androidpublisher" +IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE = "Ignoring notification from apple since we are only expecting" \ + " refund notifications" +LOGGER_BASKET_ALREADY_PURCHASED = "Basket creation failed for user [%s] with SKUS [%s]. Products already purchased" +LOGGER_BASKET_CREATED = "Basket created for user [%s] with SKUS [%s]" +LOGGER_BASKET_CREATION_FAILED = "Basket creation failed for user [%s]. Error: [%s]" +LOGGER_BASKET_NOT_FOUND = "Basket [%s] not found for user [%s]." +LOGGER_CHECKOUT_ERROR = "Checkout failed with the error [%s] and status code [%s]." +LOGGER_EXECUTE_ALREADY_PURCHASED = "Execute payment failed for user [%s] and basket [%s]. " \ + "Products already purchased." +LOGGER_EXECUTE_GATEWAY_ERROR = "Execute payment validation failed for user [%s] and basket [%s]. Error: [%s]" +LOGGER_EXECUTE_ORDER_CREATION_FAILED = "Execute payment failed for user [%s] and basket [%s]. " \ + "Order Creation failed with error [%s]." +LOGGER_EXECUTE_PAYMENT_ERROR = "Execute payment failed for user [%s] and basket [%s]. " \ + "Payment error [%s]." +LOGGER_EXECUTE_REDUNDANT_PAYMENT = "Execute payment failed for user [%s] and basket [%s]. " \ + "Redundant payment." +LOGGER_EXECUTE_STARTED = "Beginning Payment execution for user [%s], basket [%s], processor [%s]" +LOGGER_EXECUTE_SUCCESSFUL = "Payment execution successful for user [%s], basket [%s], processor [%s]" +LOGGER_PAYMENT_FAILED_FOR_BASKET = "Attempts to handle payment for basket [%s] failed with error [%s]." +LOGGER_REFUND_SUCCESSFUL = "Refund successful. OrderId: [%s] Processor: [%s] " LOGGER_STARTING_PAYMENT_FLOW = "Starting payment flow for user [%s] for products [%s]." NO_PRODUCT_AVAILABLE = "No product is available to buy." PRODUCTS_DO_NOT_EXIST = "Products with SKU(s) [{skus}] do not exist." PRODUCT_IS_NOT_AVAILABLE = "Product [%s] is not available to buy." +RECEIVED_NOTIFICATION_FROM_APPLE = "Received notification from apple with notification type [%s]" SEGMENT_MOBILE_BASKET_ADD = "Mobile Basket Add Items View Called" SEGMENT_MOBILE_PURCHASE_VIEW = "Mobile Course Purchase View Called" diff --git a/ecommerce/extensions/iap/api/v1/exceptions.py b/ecommerce/extensions/iap/api/v1/exceptions.py new file mode 100644 index 00000000000..c1834f1ad3a --- /dev/null +++ b/ecommerce/extensions/iap/api/v1/exceptions.py @@ -0,0 +1,9 @@ +""" +Exceptions used by the iap v1 api. +""" + + +class RefundCompletionException(Exception): + """ + Exception if a refund is not approved + """ diff --git a/ecommerce/extensions/iap/api/v1/ios_validator.py b/ecommerce/extensions/iap/api/v1/ios_validator.py index 9cd0c882a15..40ccae1473e 100644 --- a/ecommerce/extensions/iap/api/v1/ios_validator.py +++ b/ecommerce/extensions/iap/api/v1/ios_validator.py @@ -12,10 +12,9 @@ def validate(self, receipt, configuration): Apple for the mentioned productId. """ bundle_id = configuration.get('ios_bundle_id') - # If True, automatically query sandbox endpoint if validation fails on production endpoint - # TODO: Add auto_retry_wrong_env_request to environment variables - auto_retry_wrong_env_request = True - validator = AppStoreValidator(bundle_id, auto_retry_wrong_env_request=auto_retry_wrong_env_request) + # auto_retry_wrong_env_request = True automatically queries sandbox endpoint if + # validation fails on production endpoint + validator = AppStoreValidator(bundle_id, auto_retry_wrong_env_request=True) try: validation_result = validator.validate( diff --git a/ecommerce/extensions/iap/api/v1/tests/test_utils.py b/ecommerce/extensions/iap/api/v1/tests/test_utils.py new file mode 100644 index 00000000000..e0367cb7c8d --- /dev/null +++ b/ecommerce/extensions/iap/api/v1/tests/test_utils.py @@ -0,0 +1,39 @@ +import mock + +from ecommerce.courses.tests.factories import CourseFactory +from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased +from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder +from ecommerce.extensions.test.factories import create_basket, create_order +from ecommerce.tests.testcases import TestCase + + +class TestProductsInBasketPurchased(TestCase): + """ Tests for products_in_basket_already_purchased method. """ + + def setUp(self): + super(TestProductsInBasketPurchased, self).setUp() + self.user = self.create_user() + self.client.login(username=self.user.username, password=self.password) + + self.course = CourseFactory(partner=self.partner) + product = self.course.create_or_update_seat('verified', False, 50) + self.basket = create_basket( + owner=self.user, site=self.site, price='50.0', product_class=product.product_class + ) + create_order(site=self.site, user=self.user, basket=self.basket) + + def test_already_purchased(self): + """ + Test products in basket already purchased by user + """ + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True): + return_value = products_in_basket_already_purchased(self.user, self.basket, self.site) + self.assertTrue(return_value) + + def test_not_purchased_yet(self): + """ + Test products in basket not yet purchased by user + """ + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=False): + return_value = products_in_basket_already_purchased(self.user, self.basket, self.site) + self.assertFalse(return_value) diff --git a/ecommerce/extensions/iap/api/v1/tests/test_views.py b/ecommerce/extensions/iap/api/v1/tests/test_views.py index b224fd9e1e3..b1ddebea2ed 100644 --- a/ecommerce/extensions/iap/api/v1/tests/test_views.py +++ b/ecommerce/extensions/iap/api/v1/tests/test_views.py @@ -1,24 +1,31 @@ import datetime +import json import urllib.error import urllib.parse +import app_store_notifications_v2_validator as asn2 import ddt import mock import pytz from django.conf import settings from django.test import override_settings from django.urls import reverse +from oauth2client.service_account import ServiceAccountCredentials from oscar.apps.order.exceptions import UnableToPlaceOrder -from oscar.apps.payment.exceptions import PaymentError +from oscar.apps.payment.exceptions import GatewayError, PaymentError from oscar.core.loading import get_class, get_model +from oscar.test.factories import BasketFactory +from rest_framework import status from testfixtures import LogCapture from ecommerce.core.tests import toggle_switch from ecommerce.coupons.tests.mixins import DiscoveryMockMixin from ecommerce.courses.tests.factories import CourseFactory from ecommerce.enterprise.tests.mixins import EnterpriseServiceMockMixin +from ecommerce.extensions.api.tests.test_authentication import AccessTokenMixin from ecommerce.extensions.basket.constants import EMAIL_OPT_IN_ATTRIBUTE from ecommerce.extensions.basket.tests.mixins import BasketMixin +from ecommerce.extensions.fulfillment.status import ORDER from ecommerce.extensions.iap.api.v1.constants import ( COURSE_ALREADY_PAID_ON_DEVICE, ERROR_ALREADY_PURCHASED, @@ -27,24 +34,44 @@ ERROR_DURING_ORDER_CREATION, ERROR_DURING_PAYMENT_HANDLING, ERROR_DURING_POST_ORDER_OP, - ERROR_WHILE_OBTAINING_BASKET_FOR_USER, + ERROR_ORDER_NOT_FOUND_FOR_REFUND, + ERROR_REFUND_NOT_COMPLETED, + ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, + IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, + LOGGER_BASKET_ALREADY_PURCHASED, + LOGGER_BASKET_CREATED, + LOGGER_BASKET_CREATION_FAILED, LOGGER_BASKET_NOT_FOUND, + LOGGER_CHECKOUT_ERROR, + LOGGER_EXECUTE_ALREADY_PURCHASED, + LOGGER_EXECUTE_GATEWAY_ERROR, + LOGGER_EXECUTE_ORDER_CREATION_FAILED, + LOGGER_EXECUTE_PAYMENT_ERROR, + LOGGER_EXECUTE_REDUNDANT_PAYMENT, + LOGGER_EXECUTE_STARTED, + LOGGER_EXECUTE_SUCCESSFUL, LOGGER_PAYMENT_FAILED_FOR_BASKET, + LOGGER_REFUND_SUCCESSFUL, + LOGGER_STARTING_PAYMENT_FLOW, NO_PRODUCT_AVAILABLE, - PRODUCTS_DO_NOT_EXIST + PRODUCTS_DO_NOT_EXIST, + RECEIVED_NOTIFICATION_FROM_APPLE ) from ecommerce.extensions.iap.api.v1.google_validator import GooglePlayValidator from ecommerce.extensions.iap.api.v1.ios_validator import IOSValidator from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer -from ecommerce.extensions.iap.api.v1.views import MobileCoursePurchaseExecutionView +from ecommerce.extensions.iap.api.v1.views import AndroidRefundView, MobileCoursePurchaseExecutionView from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError +from ecommerce.extensions.payment.models import PaymentProcessorResponse from ecommerce.extensions.payment.tests.mixins import PaymentEventsMixin +from ecommerce.extensions.refund.status import REFUND, REFUND_LINE +from ecommerce.extensions.refund.tests.mixins import RefundTestMixin from ecommerce.extensions.test.factories import create_basket, create_order from ecommerce.tests.factories import ProductFactory, StockRecordFactory -from ecommerce.tests.mixins import LmsApiMockMixin +from ecommerce.tests.mixins import JwtMixin, LmsApiMockMixin from ecommerce.tests.testcases import TestCase Basket = get_model('basket', 'Basket') @@ -57,6 +84,9 @@ Selector = get_class('partner.strategy', 'Selector') StockRecord = get_model('partner', 'StockRecord') Voucher = get_model('voucher', 'Voucher') +Option = get_model('catalogue', 'Option') +Refund = get_model('refund', 'Refund') +post_refund = get_class('refund.signals', 'post_refund') @ddt.ddt @@ -64,6 +94,7 @@ class MobileBasketAddItemsViewTests(DiscoveryMockMixin, LmsApiMockMixin, BasketM EnterpriseServiceMockMixin, TestCase): """ MobileBasketAddItemsView view tests. """ path = reverse('iap:mobile-basket-add') + logger_name = 'ecommerce.extensions.iap.api.v1.views' def setUp(self): super(MobileBasketAddItemsViewTests, self).setUp() @@ -85,9 +116,13 @@ def _get_response(self, product_skus, **url_params): def test_add_multiple_products_to_basket(self): """ Verify the basket accepts multiple products. """ - products = ProductFactory.create_batch(3, stockrecords__partner=self.partner) - response = self._get_response([product.stockrecords.first().partner_sku for product in products]) - self.assertEqual(response.status_code, 200) + with LogCapture(self.logger_name) as logger: + products = ProductFactory.create_batch(3, stockrecords__partner=self.partner) + skus = [product.stockrecords.first().partner_sku for product in products] + response = self._get_response(skus) + self.assertEqual(response.status_code, 200) + logger.check((self.logger_name, 'INFO', LOGGER_STARTING_PAYMENT_FLOW % (self.user.username, skus)), + (self.logger_name, 'INFO', LOGGER_BASKET_CREATED % (self.user.username, skus))) request = response.wsgi_request basket = Basket.get_basket(request.user, request.site) @@ -96,9 +131,12 @@ def test_add_multiple_products_to_basket(self): def test_add_multiple_products_no_skus_provided(self): """ Verify the Bad request exception is thrown when no skus are provided. """ - response = self.client.get(self.path) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json()['error'], 'No SKUs provided.') + with LogCapture(self.logger_name) as logger: + error = 'No SKUs provided.' + response = self.client.get(self.path) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['error'], error) + logger.check((self.logger_name, 'ERROR', LOGGER_BASKET_CREATION_FAILED % (self.user.username, error))) def test_add_multiple_products_no_available_products(self): """ @@ -122,12 +160,26 @@ def test_all_already_purchased_products(self): stock_record = StockRecordFactory(product=product2, partner=self.partner) catalog.stock_records.add(stock_record) - with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True): + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True), \ + LogCapture(self.logger_name) as logger: response = self._get_response( [product.stockrecords.first().partner_sku for product in [product1, product2]], ) self.assertEqual(response.status_code, 406) self.assertEqual(response.json()['error'], ERROR_ALREADY_PURCHASED) + expected_skus = [product.stockrecords.first().partner_sku for product in [product1, product2]] + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_STARTING_PAYMENT_FLOW % (self.user.username, expected_skus) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_BASKET_ALREADY_PURCHASED % (self.user.username, expected_skus) + ), + ) def test_not_already_purchased_products(self): """ @@ -254,7 +306,7 @@ def test_payment_error(self): page when payment execution fails. """ with mock.patch.object(MobileCoursePurchaseExecutionView, 'handle_payment', - side_effect=PaymentError) as fake_handle_payment: + side_effect=PaymentError('Test Error')) as fake_handle_payment: with LogCapture(self.logger_name) as logger: self._assert_response({'error': ERROR_DURING_PAYMENT_HANDLING}) self.assertTrue(fake_handle_payment.called) @@ -263,10 +315,37 @@ def test_payment_error(self): ( self.logger_name, 'INFO', - 'Payment [{payment_id}] approved by payer [{payer_id}]'.format( - payment_id=self.post_data.get('transactionId'), - payer_id=self.user.id - ) + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_EXECUTE_PAYMENT_ERROR % (self.user.username, self.basket.id, + str(fake_handle_payment.side_effect)) + ), + ) + + def test_gateway_error(self): + """ + Verify that an error is thrown when an approved payment fails to execute + """ + with mock.patch.object(MobileCoursePurchaseExecutionView, 'handle_payment', + side_effect=GatewayError('Test Error')) as fake_handle_payment: + with LogCapture(self.logger_name) as logger: + self._assert_response({'error': ERROR_DURING_PAYMENT_HANDLING}) + self.assertTrue(fake_handle_payment.called) + + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_EXECUTE_GATEWAY_ERROR % (self.user.username, self.basket.id, + str(fake_handle_payment.side_effect)) ), ) @@ -276,7 +355,7 @@ def test_unanticipated_error_during_payment_handling(self): page when payment execution fails in an unanticipated manner. """ with mock.patch.object(MobileCoursePurchaseExecutionView, 'handle_payment', - side_effect=KeyError) as fake_handle_payment: + side_effect=KeyError('Test Error')) as fake_handle_payment: with LogCapture(self.logger_name) as logger: self._assert_response({'error': ERROR_DURING_PAYMENT_HANDLING}) self.assertTrue(fake_handle_payment.called) @@ -285,7 +364,7 @@ def test_unanticipated_error_during_payment_handling(self): ( self.logger_name, 'ERROR', - LOGGER_PAYMENT_FAILED_FOR_BASKET % (self.basket.id) + LOGGER_PAYMENT_FAILED_FOR_BASKET % (self.basket.id, str(fake_handle_payment.side_effect)) ), ) @@ -295,9 +374,10 @@ def test_unable_to_place_order(self): page when the payment is executed but an order cannot be placed. """ with mock.patch.object(MobileCoursePurchaseExecutionView, 'handle_order_placement', - side_effect=UnableToPlaceOrder) as fake_handle_order_placement, \ + side_effect=UnableToPlaceOrder('Test Error')) as fake_handle_order_placement, \ mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation, \ - LogCapture(self.DUPLICATE_ORDER_LOGGER_NAME) as logger: + LogCapture(self.DUPLICATE_ORDER_LOGGER_NAME) as logger_one, \ + LogCapture(self.logger_name) as logger_two: fake_google_validation.return_value = { 'resource': { 'orderId': 'orderId.android.test.purchased' @@ -306,9 +386,21 @@ def test_unable_to_place_order(self): self._assert_response({'error': ERROR_DURING_ORDER_CREATION}) self.assertTrue(fake_google_validation.called) self.assertTrue(fake_handle_order_placement.called) - logger.check( - (self.DUPLICATE_ORDER_LOGGER_NAME, 'ERROR', self.order_placement_error_message) + logger_one.check( + (self.DUPLICATE_ORDER_LOGGER_NAME, 'ERROR', self.order_placement_error_message), ) + logger_two.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_EXECUTE_ORDER_CREATION_FAILED % (self.user.username, self.basket.id, + str(fake_handle_order_placement.side_effect)) + )) def test_unanticipated_error_during_order_placement(self): """ @@ -357,22 +449,17 @@ def test_payment_error_with_no_basket(self): self.post_data['basket_id'] = dummy_basket_id with LogCapture(self.logger_name) as logger: self._assert_response({'error': ERROR_BASKET_NOT_FOUND.format(dummy_basket_id)}) - logger.check_present((self.logger_name, 'ERROR', LOGGER_BASKET_NOT_FOUND % dummy_basket_id)) - - def test_payment_error_with_unanticipated_error_while_getting_basket(self): - """ - Verify that we fail gracefully when an unanticipated Exception occurred while - getting the basket. - """ - with mock.patch.object(MobileCoursePurchaseExecutionView, '_get_basket', side_effect=KeyError), \ - LogCapture(self.logger_name) as logger: - self._assert_response({'error': ERROR_WHILE_OBTAINING_BASKET_FOR_USER.format(self.user.email)}) - logger.check_present( + logger.check( ( self.logger_name, - 'ERROR', - ERROR_WHILE_OBTAINING_BASKET_FOR_USER.format(self.user.email) + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, dummy_basket_id, self.processor_name) ), + ( + self.logger_name, + 'ERROR', + LOGGER_BASKET_NOT_FOUND % (dummy_basket_id, self.user.username) + ) ) def test_iap_payment_execution_ios(self): @@ -383,24 +470,41 @@ def test_iap_payment_execution_ios(self): ios_post_data = self.post_data ios_post_data['payment_processor'] = IOSIAP(self.site).NAME with mock.patch.object(IOSValidator, 'validate') as fake_ios_validation: - fake_ios_validation.return_value = { - 'receipt': { - 'in_app': [{ - 'original_transaction_id': '123456', - 'transaction_id': '123456' - }] + with LogCapture(self.logger_name) as logger: + fake_ios_validation.return_value = { + 'receipt': { + 'in_app': [{ + 'original_transaction_id': '123456', + 'transaction_id': '123456', + 'product_id': 'fake_product_id' + }] + } } - } - response = self.client.post(self.path, data=ios_post_data) - order = Order.objects.get(number=self.basket.order_number) - self.assertEqual(response.json(), {'order_data': MobileOrderSerializer(order).data}) + response = self.client.post(self.path, data=ios_post_data) + order = Order.objects.get(number=self.basket.order_number) + self.assertEqual(response.json(), {'order_data': MobileOrderSerializer(order).data}) + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, + ios_post_data['payment_processor']) + ), + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_SUCCESSFUL % (self.user.username, self.basket.id, + ios_post_data['payment_processor']) + ) + ) def test_iap_payment_execution_android(self): """ Verify that a user gets successful response if payment is handled correctly and order is created successfully for Android. """ - with mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation: + with mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation, \ + LogCapture(self.logger_name) as logger: fake_google_validation.return_value = { 'resource': { 'orderId': 'orderId.android.test.purchased' @@ -409,6 +513,18 @@ def test_iap_payment_execution_android(self): response = self.client.post(self.path, data=self.post_data) order = Order.objects.get(number=self.basket.order_number) self.assertEqual(response.json(), {'order_data': MobileOrderSerializer(order).data}) + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_SUCCESSFUL % (self.user.username, self.basket.id, self.processor_name) + ) + ) def test_iap_payment_execution_basket_id_error(self): """ @@ -436,7 +552,8 @@ def test_redundant_payment_notification_error(self, mock_handle_payment): expected_response_status_code = 409 error_message = COURSE_ALREADY_PAID_ON_DEVICE.encode('UTF-8') expected_response_content = b'{"error": "%s"}' % error_message - with mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation: + with mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation, \ + LogCapture(self.logger_name) as logger: fake_google_validation.return_value = { 'resource': { 'orderId': 'orderId.android.test.purchased' @@ -446,10 +563,22 @@ def test_redundant_payment_notification_error(self, mock_handle_payment): self.assertTrue(mock_handle_payment.called) self.assertEqual(response.status_code, expected_response_status_code) self.assertEqual(response.content, expected_response_content) + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_EXECUTE_REDUNDANT_PAYMENT % (self.user.username, self.basket.id) + ), + ) @mock.patch('ecommerce.extensions.checkout.mixins.EdxOrderPlacementMixin.handle_post_order') def test_post_order_exception(self, mock_handle_post_order): - mock_handle_post_order.side_effect = ValueError() + mock_handle_post_order.side_effect = AttributeError() expected_response_status_code = 200 error_message = ERROR_DURING_POST_ORDER_OP.encode('UTF-8') expected_response_content = b'{"error": "%s"}' % error_message @@ -464,6 +593,32 @@ def test_post_order_exception(self, mock_handle_post_order): self.assertEqual(response.status_code, expected_response_status_code) self.assertEqual(response.content, expected_response_content) + def test_already_purchased_basket(self): + with mock.patch.object(GooglePlayValidator, 'validate') as fake_google_validation: + fake_google_validation.return_value = { + 'resource': { + 'orderId': 'orderId.android.test.purchased' + } + } + with mock.patch.object(UserAlreadyPlacedOrder, 'user_already_placed_order', return_value=True), \ + LogCapture(self.logger_name) as logger: + create_order(site=self.site, user=self.user, basket=self.basket) + response = self.client.post(self.path, data=self.post_data) + self.assertEqual(response.status_code, 406) + self.assertEqual(response.json().get('error'), ERROR_ALREADY_PURCHASED) + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_EXECUTE_STARTED % (self.user.username, self.basket.id, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + LOGGER_EXECUTE_ALREADY_PURCHASED % (self.user.username, self.basket.id) + ), + ) + class TestMobileCheckoutView(TestCase): """ Tests for MobileCheckoutView API view. """ @@ -487,6 +642,7 @@ def setUp(self): 'basket_id': self.basket.id, 'payment_processor': 'android-iap' } + self.logger_name = 'ecommerce.extensions.iap.api.v1.views' def test_authentication_required(self): """ Verify the endpoint requires authentication. """ @@ -497,10 +653,20 @@ def test_authentication_required(self): def test_no_basket(self): """ Verify the endpoint returns HTTP 400 if the user has no associated baskets. """ self.user.baskets.all().delete() + expected_status = 400 + expected_error = "Basket [%s] not found." % str(self.post_data['basket_id']) expected_content = b'{"error": "Basket [%s] not found."}' % str(self.post_data['basket_id']).encode() - response = self.client.post(self.path, data=self.post_data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content, expected_content) + with LogCapture(self.logger_name) as logger: + response = self.client.post(self.path, data=self.post_data) + logger.check( + ( + self.logger_name, + 'ERROR', + LOGGER_CHECKOUT_ERROR % (expected_error, expected_status) + ), + ) + self.assertEqual(response.status_code, expected_status) + self.assertEqual(response.content, expected_content) @override_settings( PAYMENT_PROCESSORS=['ecommerce.extensions.iap.processors.android_iap.AndroidIAP'] @@ -516,3 +682,336 @@ def test_view_response(self): response_data = response.json() self.assertIn(reverse('iap:iap-execute'), response_data['payment_page_url']) self.assertEqual(response_data['payment_processor'], self.processor_name) + + +class BaseRefundTests(RefundTestMixin, AccessTokenMixin, JwtMixin, TestCase): + MODEL_LOGGER_NAME = 'ecommerce.core.models' + + def setUp(self): + super(BaseRefundTests, self).setUp() + self.course_id = 'edX/DemoX/Demo_Course' + self.invalid_transaction_id = "invalid transaction" + self.valid_transaction_id = "123456" + self.entitlement_option = Option.objects.get(code='course_entitlement') + self.user = self.create_user() + self.logger_name = 'ecommerce.extensions.iap.api.v1.views' + + def assert_ok_response(self, response): + """ Assert the response has HTTP status 200 and no data. """ + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_transaction_id_not_found(self): + """ If the transaction id doesn't match, no refund IDs should be created. """ + with LogCapture(self.logger_name) as logger: + AndroidRefundView().refund(self.invalid_transaction_id, {}) + msg = ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND % (self.invalid_transaction_id, + AndroidRefundView.processor_name) + logger.check((self.logger_name, 'ERROR', msg),) + + @staticmethod + def _revoke_lines(refund): + for line in refund.lines.all(): + line.set_status(REFUND_LINE.COMPLETE) + + refund.set_status(REFUND.COMPLETE) + + def assert_refund_and_order(self, refund, order, basket, processor_response, refund_response): + """ Check if we refunded the correct order """ + self.assertEqual(refund.order, order) + self.assertEqual(refund.user, order.user) + self.assertEqual(refund.status, 'Complete') + self.assertEqual(refund.total_credit_excl_tax, order.total_excl_tax) + self.assertEqual(refund.lines.count(), order.lines.count()) + + self.assertEqual(basket, processor_response.basket) + self.assertEqual(refund_response.transaction_id, processor_response.transaction_id) + + def test_refund_completion_error(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + order = self.create_order() + PaymentProcessorResponse.objects.create(basket=order.basket, + transaction_id=self.valid_transaction_id, + processor_name=AndroidRefundView.processor_name, + response=json.dumps({'state': 'approved'})) + + def _revoke_lines(refund): + for line in refund.lines.all(): + line.set_status(REFUND_LINE.COMPLETE) + + refund.set_status(REFUND.REVOCATION_ERROR) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=_revoke_lines, autospec=True): + refund_payload = {"state": "refund"} + msg = ERROR_REFUND_NOT_COMPLETED % (self.user.username, self.course_id, AndroidRefundView.processor_name) + + with LogCapture(self.logger_name) as logger: + AndroidRefundView().refund(self.valid_transaction_id, refund_payload) + self.assertFalse(Refund.objects.exists()) + self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1) + # logger.check((self.logger_name, 'ERROR', msg),) + + # A second call should ensure the atomicity of the refund logic + AndroidRefundView().refund(self.valid_transaction_id, refund_payload) + self.assertFalse(Refund.objects.exists()) + self.assertEqual(len(PaymentProcessorResponse.objects.all()), 1) + logger.check( + (self.logger_name, 'ERROR', msg), + (self.logger_name, 'ERROR', msg) + ) + + def test_valid_order(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + order = self.create_order() + basket = order.basket + self.assertFalse(Refund.objects.exists()) + processor_response = PaymentProcessorResponse.objects.create(basket=basket, + transaction_id=self.valid_transaction_id, + processor_name=AndroidRefundView.processor_name, + response=json.dumps({'state': 'approved'})) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True): + refund_payload = {"state": "refund"} + AndroidRefundView().refund(self.valid_transaction_id, refund_payload) + refund = Refund.objects.latest() + refund_response = PaymentProcessorResponse.objects.latest() + + self.assert_refund_and_order(refund, order, basket, processor_response, refund_response) + + # A second call should result in no additional refunds being created + with LogCapture(self.logger_name) as logger: + AndroidRefundView().refund(self.valid_transaction_id, {}) + msg = ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.valid_transaction_id, AndroidRefundView.processor_name) + logger.check((self.logger_name, 'ERROR', msg),) + + +class AndroidRefundTests(BaseRefundTests): + path = reverse('iap:android-refund') + order_id_one = "1234" + order_id_two = "5678" + mock_processor_response = { + "voidedPurchases": [ + { + "purchaseToken": "purchase_token", + "purchaseTimeMillis": "1677275637963", + "voidedTimeMillis": "1677650787656", + "orderId": order_id_one, + "voidedSource": 1, + "voidedReason": 1, + "kind": "androidpublisher#voidedPurchase" + }, + { + "purchaseToken": "purchase_token", + "purchaseTimeMillis": "1674131262110", + "voidedTimeMillis": "1677671872090", + "orderId": order_id_two, + "voidedSource": 0, + "voidedReason": 0, + "kind": "androidpublisher#voidedPurchase" + } + ] + } + logger_name = 'ecommerce.extensions.iap.api.v1.views' + processor_name = AndroidIAP.NAME + + def check_record_not_found_log(self, logger, msg_t): + response = self.client.get(self.path) + self.assert_ok_response(response) + refunds = self.mock_processor_response['voidedPurchases'] + msgs = [msg_t % (refund['orderId'], self.processor_name) for refund in refunds] + logger.check( + (self.logger_name, 'ERROR', msgs[0]), + (self.logger_name, 'ERROR', msgs[1]) + ) + + def test_transaction_id_not_found(self): + """ If the transaction id doesn't match, no refund IDs should be created. """ + + with mock.patch.object(ServiceAccountCredentials, 'from_json_keyfile_dict') as mock_credential_method, \ + mock.patch('ecommerce.extensions.iap.api.v1.views.build') as mock_build, \ + LogCapture(self.logger_name) as logger, \ + mock.patch('httplib2.Http'): + + mock_credential_method.return_value.authorize.return_value = None + mock_build.return_value.purchases.return_value.voidedpurchases.return_value\ + .list.return_value.execute.return_value = self.mock_processor_response + self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND) + + def test_valid_orders(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + orders = [self.create_order()] + self.assertFalse(Refund.objects.exists()) + baskets = [BasketFactory(site=self.site, owner=self.user)] + baskets[0].add_product(self.verified_product) + + second_course = CourseFactory( + id=u'edX/DemoX/Demo_Coursesecond', name=u'edX Demó Course second', partner=self.partner + ) + second_verified_product = second_course.create_or_update_seat('verified', True, 10) + baskets.append(BasketFactory(site=self.site, owner=self.user)) + baskets[1].add_product(second_verified_product) + orders.append(create_order(basket=baskets[1], user=self.user)) + orders[1].status = ORDER.COMPLETE + + payment_processor_responses = [] + for index in range(len(baskets)): + transaction_id = self.mock_processor_response['voidedPurchases'][index]['orderId'] + payment_processor_responses.append( + PaymentProcessorResponse.objects.create(basket=baskets[0], transaction_id=transaction_id, + processor_name=AndroidRefundView.processor_name, + response=json.dumps({'state': 'approved'}))) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \ + mock.patch.object(ServiceAccountCredentials, 'from_json_keyfile_dict') as mock_credential_method, \ + mock.patch('ecommerce.extensions.iap.api.v1.views.build') as mock_build, \ + mock.patch('httplib2.Http'), \ + LogCapture(self.logger_name) as logger: + + mock_credential_method.return_value.authorize.return_value = None + mock_build.return_value.purchases.return_value.voidedpurchases.return_value.\ + list.return_value.execute.return_value = self.mock_processor_response + + response = self.client.get(self.path) + self.assert_ok_response(response) + logger.check( + ( + self.logger_name, + 'INFO', + LOGGER_REFUND_SUCCESSFUL % (self.order_id_one, self.processor_name) + ), + ( + self.logger_name, + 'ERROR', + ERROR_ORDER_NOT_FOUND_FOR_REFUND % (self.order_id_two, self.processor_name) + ) + + ) + + refunds = Refund.objects.all() + refund_responses = PaymentProcessorResponse.objects.all().order_by('-id')[:1] + for index, _ in enumerate(refunds): + self.assert_refund_and_order(refunds[index], orders[index], baskets[index], + payment_processor_responses[index], refund_responses[index]) + + # A second call should result in no additional refunds being created + with LogCapture(self.logger_name) as logger: + self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND) + + +class IOSRefundTests(BaseRefundTests): + path = reverse('iap:ios-refund') + order_id_one = "1234" + mock_processor_response = { + "notificationType": "REFUND", + "notificationUUID": "3e16e420", + "data": { + "bundleId": "test.mobile", + "environment": "Sandbox", + "signedTransactionInfo": { + "originalTransactionId": "1234" + } + }, + "version": "2.0", + "signedDate": 1679801012716 + } + logger_name = 'ecommerce.extensions.iap.api.v1.views' + processor_name = IOSIAP.NAME + + def assert_error_response(self, response): + """ Assert the response has HTTP status 200 and no data. """ + self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) + + def check_record_not_found_log(self, logger, msg_t): + response = self.client.post(self.path) + self.assert_error_response(response) + transaction = self.mock_processor_response['data']['signedTransactionInfo']['originalTransactionId'] + msg = msg_t % (transaction, self.processor_name) + info = RECEIVED_NOTIFICATION_FROM_APPLE % "REFUND" + logger.check( + (self.logger_name, "INFO", info), + (self.logger_name, 'ERROR', msg) + ) + + def test_transaction_id_not_found(self): + """ If the transaction id doesn't match, no refund IDs should be created. """ + + with mock.patch.object(asn2, 'parse') as mock_ios_response_parse, \ + LogCapture(self.logger_name) as logger: + + mock_ios_response_parse.return_value = self.mock_processor_response + self.check_record_not_found_log(logger, ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND) + + def test_valid_orders(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + order = self.create_order() + self.assertFalse(Refund.objects.exists()) + basket = BasketFactory(site=self.site, owner=self.user) + basket.add_product(self.verified_product) + + transaction_id = self.mock_processor_response['data']['signedTransactionInfo']['originalTransactionId'] + processor_response = PaymentProcessorResponse.objects.create(basket=basket, transaction_id=transaction_id, + processor_name=self.processor_name, + response=json.dumps(self.mock_processor_response)) + + with mock.patch.object(Refund, '_revoke_lines', side_effect=BaseRefundTests._revoke_lines, autospec=True), \ + mock.patch.object(asn2, 'parse') as mock_ios_response_parse, \ + LogCapture(self.logger_name) as logger: + + mock_ios_response_parse.return_value = self.mock_processor_response + response = self.client.post(self.path) + self.assert_ok_response(response) + logger.check( + ( + self.logger_name, + 'INFO', + RECEIVED_NOTIFICATION_FROM_APPLE % "REFUND" + ), + ( + self.logger_name, + 'INFO', + LOGGER_REFUND_SUCCESSFUL % (self.order_id_one, self.processor_name) + ), + ) + + refunds = Refund.objects.all() + refund_responses = PaymentProcessorResponse.objects.all() + self.assertEqual(len(refunds), 1) + self.assert_refund_and_order(refunds[0], order, basket, + processor_response, refund_responses[0]) + + # A second call should result in no additional refunds being created + with LogCapture(self.logger_name) as logger: + self.check_record_not_found_log(logger, ERROR_ORDER_NOT_FOUND_FOR_REFUND) + + def test_non_refund_notification(self): + """ + View should create a refund if an order/line are found eligible for refund. + """ + + with mock.patch.object(asn2, 'parse') as mock_ios_response_parse,\ + LogCapture(self.logger_name) as logger: + + non_refund_payload = self.mock_processor_response.copy() + non_refund_payload['notificationType'] = 'TEST' + mock_ios_response_parse.return_value = non_refund_payload + response = self.client.post(self.path) + self.assert_ok_response(response) + logger.check( + ( + self.logger_name, + 'INFO', + RECEIVED_NOTIFICATION_FROM_APPLE % "TEST" + ), + ( + self.logger_name, + 'INFO', + IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE + ) + ) diff --git a/ecommerce/extensions/iap/api/v1/urls.py b/ecommerce/extensions/iap/api/v1/urls.py index 925f36fc211..f9a142ef440 100644 --- a/ecommerce/extensions/iap/api/v1/urls.py +++ b/ecommerce/extensions/iap/api/v1/urls.py @@ -1,6 +1,8 @@ from django.conf.urls import url from ecommerce.extensions.iap.api.v1.views import ( + AndroidRefundView, + IOSRefundView, MobileBasketAddItemsView, MobileCheckoutView, MobileCoursePurchaseExecutionView @@ -8,6 +10,8 @@ urlpatterns = [ url(r'^basket/add/$', MobileBasketAddItemsView.as_view(), name='mobile-basket-add'), - url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'), url(r'^checkout/$', MobileCheckoutView.as_view(), name='iap-checkout'), + url(r'^execute/$', MobileCoursePurchaseExecutionView.as_view(), name='iap-execute'), + url(r'^android/refund/$', AndroidRefundView.as_view(), name='android-refund'), + url(r'^ios/refund/$', IOSRefundView.as_view(), name='ios-refund'), ] diff --git a/ecommerce/extensions/iap/api/v1/utils.py b/ecommerce/extensions/iap/api/v1/utils.py new file mode 100644 index 00000000000..cb694ce6895 --- /dev/null +++ b/ecommerce/extensions/iap/api/v1/utils.py @@ -0,0 +1,19 @@ + + +from oscar.core.loading import get_model + +from ecommerce.extensions.order.utils import UserAlreadyPlacedOrder + +Product = get_model('catalogue', 'Product') + + +def products_in_basket_already_purchased(user, basket, site): + """ + Check if products in a basket are already purchased by a user. + """ + products = Product.objects.filter(line__order__basket=basket) + for product in products: + if not product.is_enrollment_code_product and \ + UserAlreadyPlacedOrder.user_already_placed_order(user=user, product=product, site=site): + return True + return False diff --git a/ecommerce/extensions/iap/api/v1/views.py b/ecommerce/extensions/iap/api/v1/views.py index 6f8d12dc7ed..c80d4605235 100644 --- a/ecommerce/extensions/iap/api/v1/views.py +++ b/ecommerce/extensions/iap/api/v1/views.py @@ -1,16 +1,26 @@ +import datetime import logging import time +import app_store_notifications_v2_validator as asn2 +import httplib2 +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.http import JsonResponse from django.utils.decorators import method_decorator from django.utils.html import escape from django.utils.translation import ugettext as _ +from edx_django_utils import monitoring as monitoring_utils +from edx_rest_framework_extensions.permissions import LoginRedirectIfUnauthenticated +from googleapiclient.discovery import build +from oauth2client.service_account import ServiceAccountCredentials from oscar.apps.basket.views import * # pylint: disable=wildcard-import, unused-wildcard-import -from oscar.apps.payment.exceptions import PaymentError +from oscar.apps.payment.exceptions import GatewayError, PaymentError from oscar.core.loading import get_class, get_model +from rest_framework import status from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response from rest_framework.views import APIView from ecommerce.extensions.analytics.utils import track_segment_event @@ -32,23 +42,44 @@ ERROR_DURING_ORDER_CREATION, ERROR_DURING_PAYMENT_HANDLING, ERROR_DURING_POST_ORDER_OP, - ERROR_WHILE_OBTAINING_BASKET_FOR_USER, + ERROR_ORDER_NOT_FOUND_FOR_REFUND, + ERROR_REFUND_NOT_COMPLETED, + ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, + GOOGLE_PUBLISHER_API_SCOPE, + IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE, + LOGGER_BASKET_ALREADY_PURCHASED, + LOGGER_BASKET_CREATED, + LOGGER_BASKET_CREATION_FAILED, LOGGER_BASKET_NOT_FOUND, - LOGGER_PAYMENT_APPROVED, + LOGGER_CHECKOUT_ERROR, + LOGGER_EXECUTE_ALREADY_PURCHASED, + LOGGER_EXECUTE_GATEWAY_ERROR, + LOGGER_EXECUTE_ORDER_CREATION_FAILED, + LOGGER_EXECUTE_PAYMENT_ERROR, + LOGGER_EXECUTE_REDUNDANT_PAYMENT, + LOGGER_EXECUTE_STARTED, + LOGGER_EXECUTE_SUCCESSFUL, LOGGER_PAYMENT_FAILED_FOR_BASKET, + LOGGER_REFUND_SUCCESSFUL, LOGGER_STARTING_PAYMENT_FLOW, NO_PRODUCT_AVAILABLE, PRODUCT_IS_NOT_AVAILABLE, PRODUCTS_DO_NOT_EXIST, + RECEIVED_NOTIFICATION_FROM_APPLE, SEGMENT_MOBILE_BASKET_ADD, SEGMENT_MOBILE_PURCHASE_VIEW ) +from ecommerce.extensions.iap.api.v1.exceptions import RefundCompletionException from ecommerce.extensions.iap.api.v1.serializers import MobileOrderSerializer +from ecommerce.extensions.iap.api.v1.utils import products_in_basket_already_purchased +from ecommerce.extensions.iap.models import IAPProcessorConfiguration from ecommerce.extensions.iap.processors.android_iap import AndroidIAP from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.order.exceptions import AlreadyPlacedOrderException from ecommerce.extensions.partner.shortcuts import get_partner_for_site from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError +from ecommerce.extensions.refund.api import create_refunds, find_orders_associated_with_course +from ecommerce.extensions.refund.status import REFUND Applicator = get_class('offer.applicator', 'Applicator') BasketAttribute = get_model('basket', 'BasketAttribute') @@ -62,7 +93,7 @@ class MobileBasketAddItemsView(BasketLogicMixin, APIView): """ View that adds single or multiple products to a mobile user's basket. """ - permission_classes = (IsAuthenticated,) + permission_classes = (LoginRedirectIfUnauthenticated,) def get(self, request): # Send time when this view is called - https://openedx.atlassian.net/browse/REV-984 @@ -79,15 +110,19 @@ def get(self, request): try: basket = prepare_basket(request, available_products) except AlreadyPlacedOrderException: - return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=406) + logger.exception(LOGGER_BASKET_ALREADY_PURCHASED, request.user.username, skus) + return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=status.HTTP_406_NOT_ACCEPTABLE) set_email_preference_on_basket(request, basket) + logger.info(LOGGER_BASKET_CREATED, request.user.username, skus) + return JsonResponse({'success': _(COURSE_ADDED_TO_BASKET), 'basket_id': basket.id}, - status=200) + status=status.HTTP_200_OK) - except BadRequestException as e: - return JsonResponse({'error': str(e)}, status=400) + except BadRequestException as exc: + logger.exception(LOGGER_BASKET_CREATION_FAILED, request.user.username, str(exc)) + return JsonResponse({'error': str(exc)}, status=status.HTTP_400_BAD_REQUEST) def _get_skus(self, request): skus = [escape(sku) for sku in request.GET.getlist('sku')] @@ -120,6 +155,18 @@ def _get_available_products(self, request, products): return available_products +class MobileCheckoutView(APIView): + permission_classes = (IsAuthenticated,) + + def post(self, request): + response = CheckoutView.as_view()(request._request) # pylint: disable=W0212 + if response.status_code != 200: + logger.exception(LOGGER_CHECKOUT_ERROR, response.content.decode(), response.status_code) + return JsonResponse({'error': response.content.decode()}, status=response.status_code) + + return response + + class MobileCoursePurchaseExecutionView(EdxOrderPlacementMixin, APIView): """ View that verifies an in-app purchase and completes an order for a user. @@ -135,15 +182,12 @@ def payment_processor(self): def _get_basket(self, request, basket_id): """ - Retrieve a basket using a payment ID. - + Retrieve a basket using a basket ID. Arguments: - payment_id: payment_id received from PayPal. - + basket_id: basket_id representing basket. Returns: - It will return related basket or log exception and return None if - duplicate payment_id received or any other exception occurred. - + It will return related basket or raise AlreadyPlacedOrderException + if products in basket have already been purchased. """ basket = request.user.baskets.get(id=basket_id) basket.strategy = request.strategy @@ -151,6 +195,9 @@ def _get_basket(self, request, basket_id): Applicator().apply(basket, basket.owner, self.request) basket_add_organization_attribute(basket, self.request.GET) + if products_in_basket_already_purchased(request.user, basket, request.site): + raise AlreadyPlacedOrderException + return basket # Disable atomicity for the view. Otherwise, we'd be unable to commit to the database @@ -169,52 +216,151 @@ def post(self, request): basket_id = receipt.get('basket_id') if not basket_id: return JsonResponse({'error': ERROR_BASKET_ID_NOT_PROVIDED}, status=400) - logger.info(LOGGER_PAYMENT_APPROVED, receipt.get('transactionId'), request.user.id) + logger.info(LOGGER_EXECUTE_STARTED, request.user.username, basket_id, self.payment_processor.NAME) try: basket = self._get_basket(request, basket_id) except ObjectDoesNotExist: - logger.exception(LOGGER_BASKET_NOT_FOUND, basket_id) + logger.exception(LOGGER_BASKET_NOT_FOUND, basket_id, request.user.username) return JsonResponse({'error': ERROR_BASKET_NOT_FOUND.format(basket_id)}, status=400) - except: # pylint: disable=bare-except - error_message = ERROR_WHILE_OBTAINING_BASKET_FOR_USER.format(request.user.email) - logger.exception(error_message) - return JsonResponse({'error': error_message}, status=400) + except AlreadyPlacedOrderException: + logger.exception(LOGGER_EXECUTE_ALREADY_PURCHASED, request.user.username, basket_id) + return JsonResponse({'error': _(ERROR_ALREADY_PURCHASED)}, status=406) try: with transaction.atomic(): - try: - self.handle_payment(receipt, basket) - except RedundantPaymentNotificationError: - return JsonResponse({'error': COURSE_ALREADY_PAID_ON_DEVICE}, status=409) - except PaymentError: - return JsonResponse({'error': ERROR_DURING_PAYMENT_HANDLING}, status=400) - except: # pylint: disable=bare-except - logger.exception(LOGGER_PAYMENT_FAILED_FOR_BASKET, basket.id) + self.handle_payment(receipt, basket) + order = self.create_order(request, basket) + self.handle_post_order(order) + logger.info(LOGGER_EXECUTE_SUCCESSFUL, request.user.username, basket_id, self.payment_processor.NAME) + response = {'order_data': MobileOrderSerializer(order, context={'request': request}).data} + return JsonResponse(response, status=200) + except GatewayError as exception: + logger.exception(LOGGER_EXECUTE_GATEWAY_ERROR, request.user.username, basket_id, str(exception)) return JsonResponse({'error': ERROR_DURING_PAYMENT_HANDLING}, status=400) - - try: - order = self.create_order(request, basket) - except Exception: # pylint: disable=broad-except - # Any errors here will be logged in the create_order method. If we wanted any - # IAP specific logging for this error, we would do that here. + except KeyError as exception: + logger.exception(LOGGER_PAYMENT_FAILED_FOR_BASKET, basket_id, str(exception)) + return JsonResponse({'error': ERROR_DURING_PAYMENT_HANDLING}, status=400) + except RedundantPaymentNotificationError: + logger.exception(LOGGER_EXECUTE_REDUNDANT_PAYMENT, request.user.username, basket_id) + return JsonResponse({'error': COURSE_ALREADY_PAID_ON_DEVICE}, status=409) + except PaymentError as exception: + logger.exception(LOGGER_EXECUTE_PAYMENT_ERROR, request.user.username, basket_id, str(exception)) + return JsonResponse({'error': ERROR_DURING_PAYMENT_HANDLING}, status=400) + except AttributeError: + self.log_order_placement_exception(basket.order_number, basket.id) + return JsonResponse({'error': ERROR_DURING_POST_ORDER_OP}, status=200) + except Exception as exception: # pylint: disable=broad-except + logger.exception(LOGGER_EXECUTE_ORDER_CREATION_FAILED, request.user.username, basket_id, str(exception)) return JsonResponse({'error': ERROR_DURING_ORDER_CREATION}, status=400) + +class BaseRefund(APIView): + """ Base refund class for iOS and Android refunds """ + authentication_classes = () + + def refund(self, transaction_id, processor_response): + """ Get a transaction id and create a refund against that transaction. """ + is_refunded = False + original_purchase = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id, + processor_name=self.processor_name).first() + if not original_purchase: + logger.error(ERROR_TRANSACTION_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name) + return is_refunded + + basket = original_purchase.basket + user = basket.owner + course_key = basket.all_lines().first().product.attr.course_key + orders = find_orders_associated_with_course(user, course_key) try: - self.handle_post_order(order) - except Exception: # pylint: disable=broad-except - self.log_order_placement_exception(basket.order_number, basket.id) - return JsonResponse({'error': ERROR_DURING_POST_ORDER_OP}, status=200) + with transaction.atomic(): + refunds = create_refunds(orders, course_key) + if not refunds: + monitoring_utils.set_custom_attribute('iap_no_order_to_refund', transaction_id) + logger.error(ERROR_ORDER_NOT_FOUND_FOR_REFUND, transaction_id, self.processor_name) + return is_refunded - return JsonResponse({'order_data': MobileOrderSerializer(order, context={'request': request}).data}, status=200) + refund = refunds[0] + refund.approve(revoke_fulfillment=True) + if refund.status != REFUND.COMPLETE: + monitoring_utils.set_custom_attribute('iap_unrefunded_order', transaction_id) + raise RefundCompletionException + PaymentProcessorResponse.objects.create(processor_name=self.processor_name, + transaction_id=transaction_id, + response=processor_response, basket=basket) + logger.info(LOGGER_REFUND_SUCCESSFUL, transaction_id, self.processor_name) + is_refunded = True -class MobileCheckoutView(APIView): - permission_classes = (IsAuthenticated,) + except RefundCompletionException: + logger.exception(ERROR_REFUND_NOT_COMPLETED, user.username, course_key, self.processor_name) + + return is_refunded + + +class AndroidRefundView(BaseRefund): + """ + Create refunds for orders refunded by google and un-enroll users from relevant courses + """ + processor_name = AndroidIAP.NAME + timeout = 30 + + def get(self, request): + """ + Get all refunds in last 3 days from voidedpurchases api + and call refund method on every refund. + """ + + partner_short_code = request.site.siteconfiguration.partner.short_code + configuration = settings.PAYMENT_PROCESSOR_CONFIG[partner_short_code.lower()][self.processor_name.lower()] + service = self._get_service(configuration) + + refunds_age = IAPProcessorConfiguration.get_solo().android_refunds_age_in_days + refunds_time = datetime.datetime.now() - datetime.timedelta(days=refunds_age) + refunds_time_in_ms = round(refunds_time.timestamp() * 1000) + refund_list = service.purchases().voidedpurchases() + refunds = refund_list.list(packageName=configuration['google_bundle_id'], + startTime=refunds_time_in_ms).execute() + for refund in refunds.get('voidedPurchases', []): + self.refund(refund['orderId'], refund) + + return Response() + + def _get_service(self, configuration): + """ Create a service to interact with google api. """ + play_console_credentials = configuration.get('google_service_account_key_file') + credentials = ServiceAccountCredentials.from_json_keyfile_dict(play_console_credentials, + GOOGLE_PUBLISHER_API_SCOPE) + http = httplib2.Http(timeout=self.timeout) + http = credentials.authorize(http) + + service = build("androidpublisher", "v3", http=http) + return service + + +class IOSRefundView(BaseRefund): + processor_name = IOSIAP.NAME def post(self, request): - response = CheckoutView.as_view()(request._request) # pylint: disable=W0212 - if response.status_code != 200: - return JsonResponse({'error': response.content.decode()}, status=response.status_code) + """ + This endpoint is registered as a callback for every refund made in Appstore. + It receives refund data and un enrolls user from the related course. + If we don't send back 200 response to the Appstore, it will retry this url multiple times. + """ + is_refunded = False + try: + apple_cert_file_path = "ecommerce/extensions/iap/api/v1/AppleRootCA-G3.cer" + refund_data = asn2.parse(request.body, apple_root_cert_path=apple_cert_file_path) + logger.info(RECEIVED_NOTIFICATION_FROM_APPLE, refund_data['notificationType']) + if refund_data['notificationType'] == 'REFUND': + original_transaction_id = refund_data['data']['signedTransactionInfo']['originalTransactionId'] + is_refunded = self.refund(original_transaction_id, refund_data) + else: + logger.info(IGNORE_NON_REFUND_NOTIFICATION_FROM_APPLE) + return Response(status=status.HTTP_200_OK) - return response + except Exception: # pylint: disable=broad-except + pass + + status_code = status.HTTP_200_OK if is_refunded else status.HTTP_500_INTERNAL_SERVER_ERROR + return Response(status=status_code) diff --git a/ecommerce/extensions/iap/migrations/0003_iapprocessorconfiguration_android_refunds_age_in_days.py b/ecommerce/extensions/iap/migrations/0003_iapprocessorconfiguration_android_refunds_age_in_days.py new file mode 100644 index 00000000000..c67bb6e6b7b --- /dev/null +++ b/ecommerce/extensions/iap/migrations/0003_iapprocessorconfiguration_android_refunds_age_in_days.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.15 on 2023-03-13 22:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('iap', '0002_paymentprocessorresponseextension'), + ] + + operations = [ + migrations.AddField( + model_name='iapprocessorconfiguration', + name='android_refunds_age_in_days', + field=models.PositiveSmallIntegerField(default=3, verbose_name='Past number of days to fetch Android refunds for.'), + ), + ] diff --git a/ecommerce/extensions/iap/migrations/0004_create_disbale_mobile_repeat_order_switch.py b/ecommerce/extensions/iap/migrations/0004_create_disbale_mobile_repeat_order_switch.py new file mode 100644 index 00000000000..71da7e6ca41 --- /dev/null +++ b/ecommerce/extensions/iap/migrations/0004_create_disbale_mobile_repeat_order_switch.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.15 on 2023-04-03 05:48 + +from django.db import migrations + +from ecommerce.extensions.iap.api.v1.constants import DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME + + +def create_switch(apps, schema_editor): + Switch = apps.get_model('waffle', 'Switch') + Switch.objects.get_or_create(name=DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME, defaults={'active': False}) + + +def delete_switch(apps, schema_editor): + Switch = apps.get_model('waffle', 'Switch') + Switch.objects.filter(name=DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('iap', '0003_iapprocessorconfiguration_android_refunds_age_in_days'), + ('waffle', '0001_initial'), + ] + + operations = [ + migrations.RunPython(create_switch, reverse_code=delete_switch), + ] diff --git a/ecommerce/extensions/iap/migrations/0005_paymentprocessorresponseextension_meta_data.py b/ecommerce/extensions/iap/migrations/0005_paymentprocessorresponseextension_meta_data.py new file mode 100644 index 00000000000..ea9df106053 --- /dev/null +++ b/ecommerce/extensions/iap/migrations/0005_paymentprocessorresponseextension_meta_data.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.15 on 2023-06-20 06:38 + +from django.db import migrations +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('iap', '0004_create_disbale_mobile_repeat_order_switch'), + ] + + operations = [ + migrations.AddField( + model_name='paymentprocessorresponseextension', + name='meta_data', + field=jsonfield.fields.JSONField(default={}), + ), + ] diff --git a/ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py b/ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py new file mode 100644 index 00000000000..bd39130ea1c --- /dev/null +++ b/ecommerce/extensions/iap/migrations/0006_iapprocessorconfiguration_mobile_team_email.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.19 on 2023-08-02 08:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('iap', '0005_paymentprocessorresponseextension_meta_data'), + ] + + operations = [ + migrations.AddField( + model_name='iapprocessorconfiguration', + name='mobile_team_email', + field=models.EmailField(default='', max_length=254, verbose_name='mobile team email'), + ), + ] diff --git a/ecommerce/extensions/iap/models.py b/ecommerce/extensions/iap/models.py index 1a8beeb0619..eea2659bba8 100644 --- a/ecommerce/extensions/iap/models.py +++ b/ecommerce/extensions/iap/models.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.translation import ugettext_lazy as _ +from jsonfield.fields import JSONField from solo.models import SingletonModel @@ -14,6 +15,19 @@ class IAPProcessorConfiguration(SingletonModel): ) ) + android_refunds_age_in_days = models.PositiveSmallIntegerField( + default=3, + verbose_name=_( + 'Past number of days to fetch Android refunds for.' + ) + ) + + mobile_team_email = models.EmailField( + default='', + verbose_name=_('mobile team email'), + max_length=254 + ) + class Meta: verbose_name = "IAP Processor Configuration" @@ -27,3 +41,4 @@ class PaymentProcessorResponseExtension(models.Model): related_name='extension') original_transaction_id = models.CharField(max_length=255, verbose_name=_('Original Transaction ID'), null=True, blank=True) + meta_data = JSONField(default={}) diff --git a/ecommerce/extensions/iap/processors/android_iap.py b/ecommerce/extensions/iap/processors/android_iap.py index 5ec670ea791..421c4a2525b 100644 --- a/ecommerce/extensions/iap/processors/android_iap.py +++ b/ecommerce/extensions/iap/processors/android_iap.py @@ -1,5 +1,6 @@ from ecommerce.extensions.iap.api.v1.google_validator import GooglePlayValidator from ecommerce.extensions.iap.processors.base_iap import BaseIAP +from ecommerce.extensions.payment.models import PaymentProcessorResponse class AndroidIAP(BaseIAP): # pylint: disable=W0223 @@ -12,3 +13,11 @@ class AndroidIAP(BaseIAP): # pylint: disable=W0223 def get_validator(self): return GooglePlayValidator() + + def is_payment_redundant(self, original_transaction_id=None, transaction_id=None): + """ + Return True if the transaction_id has previously been processed for a purchase. + """ + return PaymentProcessorResponse.objects.filter( + processor_name=self.NAME, + transaction_id=transaction_id).exists() diff --git a/ecommerce/extensions/iap/processors/base_iap.py b/ecommerce/extensions/iap/processors/base_iap.py index 772dbafcc5c..18fb6d9be39 100644 --- a/ecommerce/extensions/iap/processors/base_iap.py +++ b/ecommerce/extensions/iap/processors/base_iap.py @@ -2,14 +2,13 @@ from urllib.parse import urljoin import waffle -from django.db.models import Q from django.urls import reverse from oscar.apps.payment.exceptions import GatewayError, PaymentError from ecommerce.core.url_utils import get_ecommerce_url +from ecommerce.extensions.iap.api.v1.constants import DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME from ecommerce.extensions.iap.models import IAPProcessorConfiguration, PaymentProcessorResponseExtension from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError -from ecommerce.extensions.payment.models import PaymentProcessorResponse from ecommerce.extensions.payment.processors import BasePaymentProcessor, HandledProcessorResponse logger = logging.getLogger(__name__) @@ -113,16 +112,22 @@ def handle_processor_response(self, response, basket=None): ) raise GatewayError(validation_response) + if self.NAME == 'ios-iap': + validation_response = self.parse_ios_response(validation_response, product_id) + transaction_id = response.get('transactionId', self._get_transaction_id_from_receipt(validation_response)) # original_transaction_id is primary identifier for a purchase on iOS original_transaction_id = response.get('originalTransactionId', self._get_attribute_from_receipt( validation_response, 'original_transaction_id')) + currency_code = str(response.get('currency_code', '')) + price = str(response.get('price', '')) if self.NAME == 'ios-iap': if not original_transaction_id: raise PaymentError(response) - # Check for multiple edx users using same iOS device/iOS account for purchase - is_redundant_payment = self._is_payment_redundant(basket.owner, original_transaction_id) + + if not waffle.switch_is_active(DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME): + is_redundant_payment = self.is_payment_redundant(original_transaction_id, transaction_id) if is_redundant_payment: raise RedundantPaymentNotificationError(response) @@ -130,7 +135,9 @@ def handle_processor_response(self, response, basket=None): validation_response, transaction_id=transaction_id, basket=basket, - original_transaction_id=original_transaction_id + original_transaction_id=original_transaction_id, + currency_code=currency_code, + price=price ) logger.info("Successfully executed [%s] payment [%s] for basket [%d].", self.NAME, product_id, basket.id) @@ -147,7 +154,23 @@ def handle_processor_response(self, response, basket=None): card_type=None ) - def record_processor_response(self, response, transaction_id=None, basket=None, original_transaction_id=None): # pylint: disable=arguments-differ + def parse_ios_response(self, response, product_id): + """ + iOS response has multiple receipts data, and we need to select the purchase we just made + with the given product id. + """ + purchases = response['receipt'].get('in_app', []) + for purchase in purchases: + if purchase['product_id'] == product_id and \ + response['receipt']['receipt_creation_date_ms'] == purchase['purchase_date_ms']: + + response['receipt']['in_app'] = [purchase] + break + + return response + + def record_processor_response(self, response, transaction_id=None, basket=None, original_transaction_id=None, # pylint: disable=arguments-differ + currency_code=None, price=None): # pylint: disable=arguments-differ """ Save the processor's response to the database for auditing. @@ -158,19 +181,30 @@ def record_processor_response(self, response, transaction_id=None, basket=None, transaction_id (string): Identifier for the transaction on the payment processor's servers original_transaction_id (string): Identifier for the transaction for purchase action only basket (Basket): Basket associated with the payment event (e.g., being purchased) + currency_code (string): (USD, PKR, AED etc) + price (string): Price paid by end user Return PaymentProcessorResponse """ processor_response = super(BaseIAP, self).record_processor_response(response, transaction_id=transaction_id, basket=basket) - if original_transaction_id: - PaymentProcessorResponseExtension.objects.create(processor_response=processor_response, - original_transaction_id=original_transaction_id) + + meta_data = self._get_metadata(currency_code=currency_code, price=price) + PaymentProcessorResponseExtension.objects.create( + processor_response=processor_response, original_transaction_id=original_transaction_id, + meta_data=meta_data) + return processor_response def issue_credit(self, order_number, basket, reference_number, amount, currency): - raise NotImplementedError('The {} payment processor does not support credit issuance.'.format(self.NAME)) + """ + In case of mobile refund identifier is same as of transaction id or reference number. + """ + return reference_number + + def is_payment_redundant(self, original_transaction_id=None, transaction_id=None): + raise NotImplementedError def _get_attribute_from_receipt(self, validated_receipt, attribute): value = None @@ -186,6 +220,10 @@ def _get_transaction_id_from_receipt(self, validated_receipt): transaction_key = 'transaction_id' if self.NAME == 'ios-iap' else 'orderId' return self._get_attribute_from_receipt(validated_receipt, transaction_key) - def _is_payment_redundant(self, basket_owner, original_transaction_id): - return PaymentProcessorResponse.objects.filter( - ~Q(basket__owner=basket_owner), extension__original_transaction_id=original_transaction_id).exists() + def _get_metadata(self, price=None, currency_code=None): + meta_data = {} + if currency_code: + meta_data['currency_code'] = currency_code + if price: + meta_data['price'] = price + return meta_data diff --git a/ecommerce/extensions/iap/processors/ios_iap.py b/ecommerce/extensions/iap/processors/ios_iap.py index 23261c5d587..19a392410d8 100644 --- a/ecommerce/extensions/iap/processors/ios_iap.py +++ b/ecommerce/extensions/iap/processors/ios_iap.py @@ -1,5 +1,6 @@ from ecommerce.extensions.iap.api.v1.ios_validator import IOSValidator from ecommerce.extensions.iap.processors.base_iap import BaseIAP +from ecommerce.extensions.payment.models import PaymentProcessorResponse class IOSIAP(BaseIAP): # pylint: disable=W0223 @@ -11,3 +12,12 @@ class IOSIAP(BaseIAP): # pylint: disable=W0223 def get_validator(self): return IOSValidator() + + def is_payment_redundant(self, original_transaction_id=None, transaction_id=None): + """ + Return True if the original_transaction_id has previously been processed + for a purchase. + """ + return PaymentProcessorResponse.objects.filter( + processor_name=self.NAME, + extension__original_transaction_id=original_transaction_id).exists() diff --git a/ecommerce/extensions/iap/tests/processors/test_android_iap.py b/ecommerce/extensions/iap/tests/processors/test_android_iap.py index ea6f0dcdc01..8e16133147d 100644 --- a/ecommerce/extensions/iap/tests/processors/test_android_iap.py +++ b/ecommerce/extensions/iap/tests/processors/test_android_iap.py @@ -2,6 +2,7 @@ """Unit tests of Android IAP payment processor implementation.""" +import uuid from urllib.parse import urljoin import ddt @@ -15,8 +16,10 @@ from ecommerce.core.tests import toggle_switch from ecommerce.core.url_utils import get_ecommerce_url from ecommerce.extensions.checkout.utils import get_receipt_page_url +from ecommerce.extensions.iap.api.v1.constants import DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME from ecommerce.extensions.iap.api.v1.google_validator import GooglePlayValidator from ecommerce.extensions.iap.processors.android_iap import AndroidIAP +from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError from ecommerce.extensions.payment.tests.processors.mixins import PaymentProcessorTestCaseMixin from ecommerce.tests.testcases import TestCase @@ -56,6 +59,13 @@ def setUp(self): 'transactionId': 'transactionId.android.test.purchased', 'productId': 'android.test.purchased', 'purchaseToken': 'inapp:org.edx.mobile:android.test.purchased', + 'price': 40.25, + 'currency_code': 'USD', + } + self.mock_validation_response = { + 'resource': { + 'orderId': 'orderId.android.test.purchased' + } } def _get_receipt_url(self): @@ -74,6 +84,19 @@ def test_get_transaction_parameters(self): actual = self.processor.get_transaction_parameters(self.basket) self.assertEqual(actual, expected) + def test_is_payment_redundant(self): + """ + Test that True is returned only if no PaymentProcessorResponse entry is found with + the given transaction_id. + """ + transaction_id = str(uuid.uuid4()) + result = self.processor.is_payment_redundant(transaction_id=transaction_id) + self.assertFalse(result) + + PaymentProcessorResponse.objects.create(transaction_id=transaction_id, processor_name=self.processor_name) + result = self.processor.is_payment_redundant(transaction_id=transaction_id) + self.assertTrue(result) + @mock.patch.object(GooglePlayValidator, 'validate') def test_handle_processor_response_error(self, mock_google_validator): """ @@ -117,16 +140,26 @@ def test_handle_processor_response_error(self, mock_google_validator): ), ) + @mock.patch.object(AndroidIAP, 'is_payment_redundant') + @mock.patch.object(GooglePlayValidator, 'validate') + def test_handle_processor_response_redundant_error(self, mock_android_validator, mock_payment_redundant): + """ + Verify that appropriate RedundantPaymentNotificationError is raised in case payment with same + transaction_id/orderId already exists for any edx user. + """ + mock_android_validator.return_value = self.mock_validation_response + mock_payment_redundant.return_value = True + toggle_switch(DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME, False) + + with self.assertRaises(RedundantPaymentNotificationError): + self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) + @mock.patch.object(GooglePlayValidator, 'validate') def test_handle_processor_response(self, mock_google_validator): # pylint: disable=arguments-differ """ Verify that the processor creates the appropriate PaymentEvent and Source objects. """ - mock_google_validator.return_value = { - 'resource': { - 'orderId': 'orderId.android.test.purchased' - } - } + mock_google_validator.return_value = self.mock_validation_response toggle_switch('IAP_RETRY_ATTEMPTS', True) handled_response = self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) @@ -139,10 +172,46 @@ def test_issue_credit(self): """ Tests issuing credit/refund with AndroidInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) def test_issue_credit_error(self): """ Tests issuing credit/refund with AndroidInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) + + @mock.patch.object(GooglePlayValidator, 'validate') + def test_payment_processor_response_created(self, mock_google_validator): + """ + Verify that the PaymentProcessor object is created as expected. + """ + mock_google_validator.return_value = self.mock_validation_response + transaction_id = self.RETURN_DATA.get('transactionId') + + self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) + payment_processor_response = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id) + self.assertTrue(payment_processor_response.exists()) + self.assertEqual(payment_processor_response.first().processor_name, self.processor_name) + self.assertEqual(payment_processor_response.first().response, self.mock_validation_response) + + @mock.patch.object(GooglePlayValidator, 'validate') + def test_payment_processor_response_extension_created(self, mock_google_validator): + """ + Verify that the PaymentProcessorExtension object is created as expected. + """ + mock_google_validator.return_value = self.mock_validation_response + transaction_id = self.RETURN_DATA.get('transactionId') + price = str(self.RETURN_DATA.get('price')) + currency_code = self.RETURN_DATA.get('currency_code') + + self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) + + payment_processor_response = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id).first() + payment_processor_response_extension = payment_processor_response.extension + self.assertIsNotNone(payment_processor_response_extension) + self.assertEqual(payment_processor_response_extension.meta_data.get('price'), str(price)) + self.assertEqual(payment_processor_response_extension.meta_data.get('currency_code'), currency_code) diff --git a/ecommerce/extensions/iap/tests/processors/test_ios_iap.py b/ecommerce/extensions/iap/tests/processors/test_ios_iap.py index 6c3c2c658ea..2098e8d09b4 100644 --- a/ecommerce/extensions/iap/tests/processors/test_ios_iap.py +++ b/ecommerce/extensions/iap/tests/processors/test_ios_iap.py @@ -2,6 +2,7 @@ """Unit tests of IOS IAP payment processor implementation.""" +import uuid from urllib.parse import urljoin import ddt @@ -12,10 +13,12 @@ from oscar.core.loading import get_model from testfixtures import LogCapture +from ecommerce.core.tests import toggle_switch from ecommerce.core.url_utils import get_ecommerce_url from ecommerce.extensions.checkout.utils import get_receipt_page_url +from ecommerce.extensions.iap.api.v1.constants import DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME from ecommerce.extensions.iap.api.v1.ios_validator import IOSValidator -from ecommerce.extensions.iap.processors.base_iap import BaseIAP +from ecommerce.extensions.iap.models import PaymentProcessorResponseExtension from ecommerce.extensions.iap.processors.ios_iap import IOSIAP from ecommerce.extensions.payment.exceptions import RedundantPaymentNotificationError from ecommerce.extensions.payment.tests.processors.mixins import PaymentProcessorTestCaseMixin @@ -54,10 +57,42 @@ def setUp(self): u"IOSInAppPurchase's response was recorded in entry [{entry_id}]." ) self.RETURN_DATA = { - 'transactionId': 'transactionId.ios.test.purchased', - 'originalTransactionId': 'originalTransactionId.ios.test.purchased', - 'productId': 'ios.test.purchased', - 'purchaseToken': 'inapp:org.edx.mobile:ios.test.purchased', + 'transactionId': 'test_id', + 'originalTransactionId': 'original_test_id', + 'productId': 'test_product_id', + 'purchaseToken': 'inapp:test.edx.edx:ios.test.purchased', + 'price': 40.25, + 'currency_code': 'USD', + } + self.mock_validation_response = { + 'environment': 'Sandbox', + 'receipt': { + 'bundle_id': 'test_bundle_id', + 'in_app': [ + { + 'in_app_ownership_type': 'PURCHASED', + 'original_transaction_id': 'very_old_purchase_id', + 'product_id': 'org.edx.mobile.test_product1', + 'purchase_date_ms': '1676562309000', + 'transaction_id': 'vaery_old_purchase_id' + }, + { + 'in_app_ownership_type': 'PURCHASED', + 'original_transaction_id': 'old_purchase_id', + 'product_id': 'org.edx.mobile.test_product3', + 'purchase_date_ms': '1676562544000', + 'transaction_id': 'old_purchase_id' + }, + { + 'in_app_ownership_type': 'PURCHASED', + 'original_transaction_id': 'original_test_id', + 'product_id': 'test_product_id', + 'purchase_date_ms': '1676562978000', + 'transaction_id': 'test_id' + } + ], + 'receipt_creation_date_ms': '1676562978000', + } } def _get_receipt_url(self): @@ -76,6 +111,22 @@ def test_get_transaction_parameters(self): actual = self.processor.get_transaction_parameters(self.basket) self.assertEqual(actual, expected) + def test_is_payment_redundant(self): + """ + Test that True is returned only if no PaymentProcessorResponseExtension entry is found with + the given original_transaction_id. + """ + original_transaction_id = str(uuid.uuid4()) + result = self.processor.is_payment_redundant(original_transaction_id=original_transaction_id) + self.assertFalse(result) + + processor_response = PaymentProcessorResponse.objects.create( + transaction_id=original_transaction_id, processor_name=self.processor_name) + PaymentProcessorResponseExtension.objects.create( + processor_response=processor_response, original_transaction_id=original_transaction_id) + result = self.processor.is_payment_redundant(original_transaction_id=original_transaction_id) + self.assertTrue(result) + @mock.patch.object(IOSValidator, 'validate') def test_handle_processor_response_gateway_error(self, mock_ios_validator): """ @@ -124,29 +175,25 @@ def test_handle_processor_response_payment_error(self, mock_ios_validator): """ Verify that appropriate PaymentError is raised in absence of originalTransactionId parameter. """ - mock_ios_validator.return_value = { - 'resource': { - 'orderId': 'orderId.ios.test.purchased' - } - } + modified_validation_response = self.mock_validation_response + modified_validation_response['receipt']['in_app'][2].pop('original_transaction_id') + mock_ios_validator.return_value = modified_validation_response with self.assertRaises(PaymentError): modified_return_data = self.RETURN_DATA modified_return_data.pop('originalTransactionId') + self.processor.handle_processor_response(modified_return_data, basket=self.basket) - @mock.patch.object(BaseIAP, '_is_payment_redundant') + @mock.patch.object(IOSIAP, 'is_payment_redundant') @mock.patch.object(IOSValidator, 'validate') def test_handle_processor_response_redundant_error(self, mock_ios_validator, mock_payment_redundant): """ Verify that appropriate RedundantPaymentNotificationError is raised in case payment with same - originalTransactionId exists with another user + originalTransactionId exists for any edx user. """ - mock_ios_validator.return_value = { - 'resource': { - 'orderId': 'orderId.ios.test.purchased' - } - } + mock_ios_validator.return_value = self.mock_validation_response mock_payment_redundant.return_value = True + toggle_switch(DISABLE_REDUNDANT_PAYMENT_CHECK_MOBILE_SWITCH_NAME, False) with self.assertRaises(RedundantPaymentNotificationError): self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) @@ -156,11 +203,7 @@ def test_handle_processor_response(self, mock_ios_validator): # pylint: disable """ Verify that the processor creates the appropriate PaymentEvent and Source objects. """ - mock_ios_validator.return_value = { - 'resource': { - 'orderId': 'orderId.ios.test.purchased' - } - } + mock_ios_validator.return_value = self.mock_validation_response handled_response = self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) self.assertEqual(handled_response.currency, self.basket.currency) @@ -172,10 +215,47 @@ def test_issue_credit(self): """ Tests issuing credit/refund with IOSInAppPurchase processor. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) def test_issue_credit_error(self): """ - Tests issuing credit/refund with IOsInAppPurchase processor. + Tests issuing credit/refund with IOSInAppPurchase processor. + """ + refund_id = "test id" + result = self.processor.issue_credit(refund_id, refund_id, refund_id, refund_id, refund_id) + self.assertEqual(refund_id, result) + + @mock.patch.object(IOSValidator, 'validate') + def test_payment_processor_response_created(self, mock_ios_validator): + """ + Verify that the PaymentProcessor object is created as expected. """ - self.assertRaises(NotImplementedError, self.processor.issue_credit, None, None, None, None, None) + mock_ios_validator.return_value = self.mock_validation_response + transaction_id = self.RETURN_DATA.get('transactionId') + + self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) + payment_processor_response = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id) + self.assertTrue(payment_processor_response.exists()) + self.assertEqual(payment_processor_response.first().processor_name, self.processor_name) + self.assertEqual(payment_processor_response.first().response, self.mock_validation_response) + + @mock.patch.object(IOSValidator, 'validate') + def test_payment_processor_response_extension_created(self, mock_ios_validator): + """ + Verify that the PaymentProcessorExtension object is created as expected. + """ + mock_ios_validator.return_value = self.mock_validation_response + transaction_id = self.RETURN_DATA.get('transactionId') + original_transaction_id = self.RETURN_DATA.get('originalTransactionId') + price = str(self.RETURN_DATA.get('price')) + currency_code = self.RETURN_DATA.get('currency_code') + + self.processor.handle_processor_response(self.RETURN_DATA, basket=self.basket) + payment_processor_response = PaymentProcessorResponse.objects.filter(transaction_id=transaction_id) + payment_processor_response_extension = payment_processor_response.first().extension + self.assertIsNotNone(payment_processor_response_extension) + self.assertEqual(payment_processor_response_extension.original_transaction_id, original_transaction_id) + self.assertEqual(payment_processor_response_extension.meta_data.get('price'), price) + self.assertEqual(payment_processor_response_extension.meta_data.get('currency_code'), currency_code) diff --git a/ecommerce/extensions/offer/dynamic_conditional_offer.py b/ecommerce/extensions/offer/dynamic_conditional_offer.py index c22fd16bd94..02773569297 100644 --- a/ecommerce/extensions/offer/dynamic_conditional_offer.py +++ b/ecommerce/extensions/offer/dynamic_conditional_offer.py @@ -4,9 +4,10 @@ """ import crum import waffle +from edx_django_utils.monitoring import set_custom_attribute +from edx_rest_framework_extensions.auth.jwt.decoder import configured_jwt_decode_handler from oscar.core.loading import get_class, get_model -from ecommerce.extensions.api.handlers import jwt_decode_handler from ecommerce.extensions.offer.constants import DYNAMIC_DISCOUNT_FLAG from ecommerce.extensions.offer.mixins import ( BenefitWithoutRangeMixin, @@ -30,9 +31,11 @@ def get_decoded_jwt_discount_from_request(): else: discount_jwt = request.POST.get('discount_jwt') if not discount_jwt: + set_custom_attribute('ecom_discount_jwt', 'not-found') return None - return jwt_decode_handler(discount_jwt) + set_custom_attribute('ecom_discount_jwt', 'found') + return configured_jwt_decode_handler(discount_jwt) def get_percentage_from_request(): diff --git a/ecommerce/extensions/offer/migrations/0053_auto_20230601_1425.py b/ecommerce/extensions/offer/migrations/0053_auto_20230601_1425.py new file mode 100644 index 00000000000..cb65bfc6e7a --- /dev/null +++ b/ecommerce/extensions/offer/migrations/0053_auto_20230601_1425.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-06-01 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('offer', '0052_jsonfield_codeassignmentnudgeemails_offerusageemail'), + ] + + operations = [ + migrations.AddField( + model_name='conditionaloffer', + name='salesforce_opportunity_line_item', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AddField( + model_name='historicalconditionaloffer', + name='salesforce_opportunity_line_item', + field=models.CharField(blank=True, max_length=30, null=True), + ), + ] diff --git a/ecommerce/extensions/offer/migrations/0054_auto_20230601_2037.py b/ecommerce/extensions/offer/migrations/0054_auto_20230601_2037.py new file mode 100644 index 00000000000..a62dee5d1b2 --- /dev/null +++ b/ecommerce/extensions/offer/migrations/0054_auto_20230601_2037.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-06-01 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('offer', '0053_auto_20230601_1425'), + ] + + operations = [ + migrations.AlterField( + model_name='conditionaloffer', + name='sales_force_id', + field=models.CharField(blank=True, default=None, max_length=30, null=True), + ), + migrations.AlterField( + model_name='historicalconditionaloffer', + name='sales_force_id', + field=models.CharField(blank=True, default=None, max_length=30, null=True), + ), + ] diff --git a/ecommerce/extensions/offer/models.py b/ecommerce/extensions/offer/models.py index 658a5ac10db..8ba3c339e0e 100644 --- a/ecommerce/extensions/offer/models.py +++ b/ecommerce/extensions/offer/models.py @@ -213,7 +213,8 @@ class ConditionalOffer(AbstractConditionalOffer): ] UPDATABLE_OFFER_FIELDS = ['email_domains', 'max_uses'] email_domains = models.CharField(max_length=255, blank=True, null=True) - sales_force_id = models.CharField(max_length=30, blank=True, null=True) + sales_force_id = models.CharField(max_length=30, blank=True, null=True, default=None) + salesforce_opportunity_line_item = models.CharField(max_length=30, blank=True, null=True) max_user_discount = models.DecimalField( verbose_name='Max user discount', max_digits=12, diff --git a/ecommerce/extensions/offer/tests/test_dynamic_conditional_offer.py b/ecommerce/extensions/offer/tests/test_dynamic_conditional_offer.py index cc42fe90f2a..a3ce50c5a1d 100644 --- a/ecommerce/extensions/offer/tests/test_dynamic_conditional_offer.py +++ b/ecommerce/extensions/offer/tests/test_dynamic_conditional_offer.py @@ -43,7 +43,7 @@ class DynamicPercentageDiscountBenefitTests(BenefitTestMixin, TestCase): @override_flag(DYNAMIC_DISCOUNT_FLAG, active=True) @patch('crum.get_current_request') - @patch('ecommerce.extensions.offer.dynamic_conditional_offer.jwt_decode_handler', + @patch('ecommerce.extensions.offer.dynamic_conditional_offer.configured_jwt_decode_handler', side_effect=_mock_jwt_decode_handler) @patch('ecommerce.enterprise.utils.get_decoded_jwt', side_effect=_mock_get_decoded_jwt) @@ -104,7 +104,7 @@ def test_name(self): @override_flag(DYNAMIC_DISCOUNT_FLAG, active=True) @patch('crum.get_current_request') - @patch('ecommerce.extensions.offer.dynamic_conditional_offer.jwt_decode_handler', + @patch('ecommerce.extensions.offer.dynamic_conditional_offer.configured_jwt_decode_handler', side_effect=_mock_jwt_decode_handler) @ddt.data( {'discount_applicable': True, 'discount_percent': 15}, diff --git a/ecommerce/extensions/payment/processors/stripe.py b/ecommerce/extensions/payment/processors/stripe.py index 918ba22d75a..ae62818ecf5 100644 --- a/ecommerce/extensions/payment/processors/stripe.py +++ b/ecommerce/extensions/payment/processors/stripe.py @@ -111,39 +111,46 @@ def get_capture_context(self, request): basket.id, basket.order_number, ) - return None - try: - stripe_response = stripe.PaymentIntent.create( - **self._build_payment_intent_parameters(basket), - # This means this payment intent can only be confirmed with secret key (as in, from ecommerce) - secret_key_confirmation='required', - # don't create a new intent for the same basket - idempotency_key=self.generate_basket_pi_idempotency_key(basket), - ) - # id is the payment_intent_id from Stripe - transaction_id = stripe_response['id'] - - basket_add_payment_intent_id_attribute(basket, transaction_id) - # for when basket was already created, but with different amount - except stripe.error.IdempotencyError: - # if this PI has been created before, we should be able to retrieve - # it from Stripe using the payment_intent_id BasketAttribute. - # Note that we update the PI's price in handle_processor_response - # before hitting the confirm endpoint, so we don't need to do that here - payment_intent_id_attribute = BasketAttributeType.objects.get(name=PAYMENT_INTENT_ID_ATTRIBUTE) - payment_intent_attr = BasketAttribute.objects.get( - basket=basket, - attribute_type=payment_intent_id_attribute - ) - transaction_id = payment_intent_attr.value_text.strip() - logger.info( - 'Idempotency Error: Retrieving existing Payment Intent for basket [%d]' - ' with transaction ID [%s] and order number [%s].', - basket.id, - transaction_id, - basket.order_number, - ) - stripe_response = stripe.PaymentIntent.retrieve(id=transaction_id) + # Create a default stripe_response object with the necessary fields to combat 400 errors + stripe_response = { + 'id': '', + 'client_secret': '', + } + else: + try: + logger.info("*** GETTING STRIPE RESPONSE ***") + stripe_response = stripe.PaymentIntent.create( + **self._build_payment_intent_parameters(basket), + # This means this payment intent can only be confirmed with secret key (as in, from ecommerce) + secret_key_confirmation='required', + # don't create a new intent for the same basket + idempotency_key=self.generate_basket_pi_idempotency_key(basket), + ) + logger.info("*** STRIPE RESPONSE %s ***", stripe_response) + # id is the payment_intent_id from Stripe + transaction_id = stripe_response['id'] + + basket_add_payment_intent_id_attribute(basket, transaction_id) + # for when basket was already created, but with different amount + except stripe.error.IdempotencyError: + # if this PI has been created before, we should be able to retrieve + # it from Stripe using the payment_intent_id BasketAttribute. + # Note that we update the PI's price in handle_processor_response + # before hitting the confirm endpoint, so we don't need to do that here + payment_intent_id_attribute = BasketAttributeType.objects.get(name=PAYMENT_INTENT_ID_ATTRIBUTE) + payment_intent_attr = BasketAttribute.objects.get( + basket=basket, + attribute_type=payment_intent_id_attribute + ) + transaction_id = payment_intent_attr.value_text.strip() + logger.info( + 'Idempotency Error: Retrieving existing Payment Intent for basket [%d]' + ' with transaction ID [%s] and order number [%s].', + basket.id, + transaction_id, + basket.order_number, + ) + stripe_response = stripe.PaymentIntent.retrieve(id=transaction_id) new_capture_context = { 'key_id': stripe_response['client_secret'], diff --git a/ecommerce/extensions/payment/serializers.py b/ecommerce/extensions/payment/serializers.py new file mode 100644 index 00000000000..2e74f6b8879 --- /dev/null +++ b/ecommerce/extensions/payment/serializers.py @@ -0,0 +1,15 @@ +"""Payment Extension Serializers. """ + +from rest_framework import serializers + +from ecommerce.extensions.payment.models import SDNCheckFailure + + +class SDNCheckFailureSerializer(serializers.ModelSerializer): + """ + Serializer for SDNCheckFailure model. + """ + + class Meta: + model = SDNCheckFailure + fields = '__all__' diff --git a/ecommerce/extensions/payment/tests/views/test_sdn.py b/ecommerce/extensions/payment/tests/views/test_sdn.py index 71a6820c5c7..a1fca2ad4ed 100644 --- a/ecommerce/extensions/payment/tests/views/test_sdn.py +++ b/ecommerce/extensions/payment/tests/views/test_sdn.py @@ -1,7 +1,12 @@ +import json +import mock from django.urls import reverse +from requests.exceptions import HTTPError +from ecommerce.extensions.api.tests.test_authentication import AccessTokenMixin +from ecommerce.extensions.payment.models import SDNCheckFailure from ecommerce.tests.testcases import TestCase @@ -13,3 +18,98 @@ def test_sdn_logout_context(self): logout_url = self.site.siteconfiguration.build_lms_url('logout') response = self.client.get(self.failure_path) self.assertEqual(response.context['logout_url'], logout_url) + + +class SDNCheckViewTests(AccessTokenMixin, TestCase): + sdn_check_path = reverse('sdn:check') + + def setUp(self): + super().setUp() + self.user = self.create_user(is_staff=True) + self.client.login(username=self.user.username, password=self.password) + self.token = self.generate_jwt_token_header(self.user) + self.post_params = { + 'lms_user_id': 1337, + 'name': 'Bowser, King of the Koopas', + 'city': 'Northern Chocolate Island', + 'country': 'Mushroom Kingdom', + } + + def test_sdn_check_missing_args(self): + response = self.client.post(self.sdn_check_path, HTTP_AUTHORIZATION=self.token) + assert response.status_code == 400 + + @mock.patch('ecommerce.extensions.payment.views.sdn.checkSDNFallback') + @mock.patch('ecommerce.extensions.payment.views.sdn.SDNClient.search') + def test_sdn_check_search_fails_uses_fallback(self, mock_search, mock_fallback): + mock_search.side_effect = [HTTPError] + mock_fallback.return_value = 0 + response = self.client.post(self.sdn_check_path, data=self.post_params, HTTP_AUTHORIZATION=self.token) + assert response.status_code == 200 + assert response.json()['hit_count'] == 0 + + @mock.patch('ecommerce.extensions.payment.views.sdn.checkSDNFallback') + @mock.patch('ecommerce.extensions.payment.views.sdn.SDNClient.search') + def test_sdn_check_search_succeeds(self, mock_search, mock_fallback): + mock_search.return_value = {'total': 4} + response = self.client.post(self.sdn_check_path, data=self.post_params, HTTP_AUTHORIZATION=self.token) + assert response.status_code == 200 + assert response.json()['hit_count'] == 4 + assert response.json()['sdn_response'] == {'total': 4} + mock_fallback.assert_not_called() + + +class SDNCheckFailureViewTests(TestCase): + sdn_check_path = reverse('sdn:metadata') + + def setUp(self): + super().setUp() + self.user = self.create_user(is_staff=True) + self.client.login(username=self.user.username, password=self.password) + self.token = self.generate_jwt_token_header(self.user) + self.post_params = { + 'full_name': 'Princess Peach', + 'username': 'toadstool_is_cool', + 'city': 'Mushroom Castle', + 'country': 'US', + 'sdn_check_response': { # This will be a large JSON blob when returned from SDN API + 'total': 1, + }, + } + + def test_non_staff_cannot_access_endpoint(self): + self.user.is_staff = False + self.user.save() + response = self.client.post(self.sdn_check_path, data=self.post_params, content_type='application/json', + HTTP_AUTHORIZATION=self.token) + assert response.status_code == 403 + + def test_missing_payload_arg_400(self): + del self.post_params['full_name'] + response = self.client.post(self.sdn_check_path, data=self.post_params, content_type='application/json', + HTTP_AUTHORIZATION=self.token) + assert response.status_code == 400 + + def test_sdn_response_response_missing_required_field_400(self): + del self.post_params['sdn_check_response']['total'] + assert 'sdn_check_response' in self.post_params # so it's clear we deleted the sub dict's key + + response = self.client.post(self.sdn_check_path, data=self.post_params, content_type='application/json', + HTTP_AUTHORIZATION=self.token) + assert response.status_code == 400 + + def test_happy_path_create(self): + assert SDNCheckFailure.objects.count() == 0 + json_payload = json.dumps(self.post_params) + response = self.client.post(self.sdn_check_path, data=json_payload, content_type='application/json', + HTTP_AUTHORIZATION=self.token) + + assert response.status_code == 201 + assert SDNCheckFailure.objects.count() == 1 + + check_failure_object = SDNCheckFailure.objects.first() + assert check_failure_object.full_name == 'Princess Peach' + assert check_failure_object.username == 'toadstool_is_cool' + assert check_failure_object.city == 'Mushroom Castle' + assert check_failure_object.country == 'US' + assert check_failure_object.sdn_check_response == {'total': 1} diff --git a/ecommerce/extensions/payment/tests/views/test_stripe.py b/ecommerce/extensions/payment/tests/views/test_stripe.py index 4c8064f49a4..cc0887c200f 100644 --- a/ecommerce/extensions/payment/tests/views/test_stripe.py +++ b/ecommerce/extensions/payment/tests/views/test_stripe.py @@ -269,11 +269,22 @@ def test_capture_context_empty_basket(self): basket.flush() with mock.patch('stripe.PaymentIntent.create') as mock_create: + mock_create.return_value = { + 'id': '', + 'client_secret': '', + } + self.assertTrue(basket.is_empty) response = self.client.get(self.capture_context_url) + mock_create.assert_not_called() - self.assertDictEqual(response.json(), {}) - self.assertEqual(response.status_code, 400) + self.assertDictEqual(response.json(), { + 'capture_context': { + 'key_id': mock_create.return_value['client_secret'], + 'order_id': basket.order_number, + } + }) + self.assertEqual(response.status_code, 200) def test_payment_error_no_basket(self): """ diff --git a/ecommerce/extensions/payment/urls.py b/ecommerce/extensions/payment/urls.py index 6ae80cadaaa..3b6cd7b02bf 100644 --- a/ecommerce/extensions/payment/urls.py +++ b/ecommerce/extensions/payment/urls.py @@ -3,7 +3,8 @@ from django.conf import settings from django.conf.urls import include, url -from ecommerce.extensions.payment.views import PaymentFailedView, SDNFailure, cybersource, paypal, stripe +from ecommerce.extensions.payment.views import PaymentFailedView, cybersource, paypal, stripe +from ecommerce.extensions.payment.views.sdn import SDNCheckFailureView, SDNCheckView, SDNFailure CYBERSOURCE_APPLE_PAY_URLS = [ url(r'^authorize/$', cybersource.CybersourceApplePayAuthorizationView.as_view(), name='authorize'), @@ -20,7 +21,9 @@ ] SDN_URLS = [ + url(r'^check/$', SDNCheckView.as_view(), name='check'), url(r'^failure/$', SDNFailure.as_view(), name='failure'), + url(r'^metadata/$', SDNCheckFailureView.as_view(), name='metadata'), ] STRIPE_URLS = [ diff --git a/ecommerce/extensions/payment/utils.py b/ecommerce/extensions/payment/utils.py index caf39c63f9f..c5ff2adbffc 100644 --- a/ecommerce/extensions/payment/utils.py +++ b/ecommerce/extensions/payment/utils.py @@ -2,6 +2,7 @@ import re from urllib.parse import urljoin +from django.contrib.auth import get_user_model from django.utils.translation import ugettext_lazy as _ from oscar.core.loading import get_model @@ -12,6 +13,7 @@ Basket = get_model('basket', 'Basket') BasketAttribute = get_model('basket', 'BasketAttribute') BasketAttributeType = get_model('basket', 'BasketAttributeType') +User = get_user_model() def get_basket_program_uuid(basket): @@ -99,7 +101,7 @@ def clean_field_value(value): return re.sub(r'[\^:"\']', '', value) -def embargo_check(user, site, products): +def embargo_check(user, site, products, ip=None): """ Checks if the user has access to purchase products by calling the LMS embargo API. Args: @@ -109,8 +111,11 @@ def embargo_check(user, site, products): Returns: Bool """ + courses = [] - _, _, ip = parse_tracking_context(user, usage='embargo') + + if not ip and isinstance(user, User): + _, _, ip = parse_tracking_context(user, usage='embargo') for product in products: # We only are checking Seats diff --git a/ecommerce/extensions/payment/views/__init__.py b/ecommerce/extensions/payment/views/__init__.py index f6db5491705..2e75577a56e 100644 --- a/ecommerce/extensions/payment/views/__init__.py +++ b/ecommerce/extensions/payment/views/__init__.py @@ -31,16 +31,6 @@ def get_context_data(self, **kwargs): return context -class SDNFailure(TemplateView): - """ Display an error page when the SDN check fails at checkout. """ - template_name = 'oscar/checkout/sdn_failure.html' - - def get_context_data(self, **kwargs): - context = super(SDNFailure, self).get_context_data(**kwargs) - context['logout_url'] = self.request.site.siteconfiguration.build_lms_url('/logout') - return context - - class BasePaymentSubmitView(View): """ Base class for payment submission views. diff --git a/ecommerce/extensions/payment/views/sdn.py b/ecommerce/extensions/payment/views/sdn.py new file mode 100644 index 00000000000..117a534580f --- /dev/null +++ b/ecommerce/extensions/payment/views/sdn.py @@ -0,0 +1,159 @@ +import logging + +from django.conf import settings +from django.http import JsonResponse +from django.views.generic import TemplateView +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from requests.exceptions import HTTPError, Timeout +from rest_framework import status, views +from rest_framework.permissions import IsAdminUser, IsAuthenticated + +from ecommerce.extensions.payment.core.sdn import SDNClient, checkSDNFallback +from ecommerce.extensions.payment.models import SDNCheckFailure +from ecommerce.extensions.payment.serializers import SDNCheckFailureSerializer + +logger = logging.getLogger(__name__) + + +class SDNCheckFailureView(views.APIView): + """ + REST API for SDNCheckFailure class. + """ + http_method_names = ['post', 'options'] + authentication_classes = (JwtAuthentication,) + permission_classes = (IsAuthenticated, IsAdminUser) + serializer_class = SDNCheckFailureSerializer + + def _validate_arguments(self, payload): + + invalid = False + reasons = [] + # Check for presence of required variables + for arg in ['full_name', 'username', 'city', 'country', 'sdn_check_response']: + if not payload.get(arg): + reason = f'{arg} is missing or blank.' + reasons.append(reason) + if reasons: + invalid = True + return invalid, reasons + + return invalid, reasons + + def post(self, request, *args, **kwargs): # pylint: disable=unused-argument + payload = request.data + invalid, reasons = self._validate_arguments(payload) + if invalid is True: + logger.warning( + 'Invalid payload for request user %s against SDNCheckFailureView endpoint. Reasons: %s', + request.user, + reasons, + ) + return JsonResponse( + {'error': ' '.join(reasons)}, + status=400, + ) + + sdn_check_failure = SDNCheckFailure.objects.create( + full_name=payload['full_name'], + username=payload['username'], + city=payload['city'], + country=payload['country'], + site=request.site, + sdn_check_response=payload['sdn_check_response'], + ) + + # This is the point where we would add the products to the SDNCheckFailure obj. + # We, however, do not know whether the products themselves are relevant to the flow + # calling this endpoint. If you wanted to attach products to the failure record, you + # can use skus handed to this endpoint to filter Products using their stockrecords: + # Product.objects.filter(stockrecords__partner_sku__in=['C92A142','ABC123']) + + # Return a response + data = self.serializer_class(sdn_check_failure, context={'request': request}).data + return JsonResponse(data, status=status.HTTP_201_CREATED) + + +class SDNFailure(TemplateView): + """ Display an error page when the SDN check fails at checkout. """ + template_name = 'oscar/checkout/sdn_failure.html' + + def get_context_data(self, **kwargs): + context = super(SDNFailure, self).get_context_data(**kwargs) + context['logout_url'] = self.request.site.siteconfiguration.build_lms_url('/logout') + return context + + +class SDNCheckView(views.APIView): + """ + View for external services to use to run SDN checks against. + + While this endpoint uses a lot of logic from sdn.py, this endpoint is + not called during a normal checkout flow (as of 6/8/2023). + """ + http_method_names = ['post', 'options'] + authentication_classes = (JwtAuthentication,) + permission_classes = (IsAuthenticated, IsAdminUser) + + def post(self, request): + """ + Use data provided to check against SDN list. + + Return a count of hits. + """ + payload = request.POST + + # Make sure we have the values needed to carry out the request + missing_args = [] + for expected_arg in ['lms_user_id', 'name', 'city', 'country']: + if not payload.get(expected_arg): + missing_args.append(expected_arg) + + if missing_args: + return JsonResponse({ + 'missing_args': ', '.join(missing_args) + }, status=400) + + # Begin the check logic + lms_user_id = payload.get('lms_user_id') + name = payload.get('name') + city = payload.get('city') + country = payload.get('country') + sdn_list = payload.get('sdn_list', 'ISN,SDN') # Set SDN lists to a sane default + + sdn_check = SDNClient( + api_url=settings.SDN_CHECK_API_URL, + api_key=settings.SDN_CHECK_API_KEY, + sdn_list=sdn_list + ) + try: + response = sdn_check.search(name, city, country) + except (HTTPError, Timeout) as e: + logger.info( + 'SDNCheck: SDN API call received an error: %s. SDNFallback function called for user %s.', + str(e), + lms_user_id + ) + sdn_fallback_hit_count = checkSDNFallback( + name, + city, + country + ) + response = {'total': sdn_fallback_hit_count} + + hit_count = response['total'] + if hit_count > 0: + logger.info( + 'SDNCheck Endpoint called for lms user [%s]. It received %d hit(s).', + lms_user_id, + hit_count, + ) + else: + logger.info( + 'SDNCheck function called for lms user [%s]. It did not receive a hit.', + lms_user_id, + ) + json_data = { + 'hit_count': hit_count, + 'sdn_response': response, + } + return JsonResponse(json_data, status=200) diff --git a/ecommerce/extensions/refund/tests/test_signals.py b/ecommerce/extensions/refund/tests/test_signals.py index 4aec4ebb944..6998f7ff834 100644 --- a/ecommerce/extensions/refund/tests/test_signals.py +++ b/ecommerce/extensions/refund/tests/test_signals.py @@ -106,4 +106,4 @@ def test_successful_refund_tracking_segment_error(self, mock_track): self.assertTrue(mock_track.called) # Verify that an error message was logged. - self.assertTrue(mock_log_exc.called_with('Failed to emit tracking event upon refund completion.')) + mock_log_exc.assert_called_with('Failed to emit tracking event upon refund completion.') diff --git a/ecommerce/management/tests/test_views.py b/ecommerce/management/tests/test_views.py index b2c460e057c..232821a8235 100644 --- a/ecommerce/management/tests/test_views.py +++ b/ecommerce/management/tests/test_views.py @@ -45,9 +45,12 @@ def test_invalid_action(self): self.assert_first_message(response, messages.ERROR, 'invalid-action is not a valid action.') def test_refund_basket_transactions(self): - with mock.patch('ecommerce.management.utils.refund_basket_transactions') as mock_refund: + success_count = 0 + failed_count = 0 + result = (success_count, failed_count) + with mock.patch('ecommerce.management.views.refund_basket_transactions', return_value=result) as mock_refund: response = self.client.post(self.path, {'action': 'refund_basket_transactions', 'basket_ids': '1,2,3'}) - assert mock_refund.called_once_with(self.site, [1, 2, 3]) + mock_refund.assert_called_once_with(self.site, [1, 2, 3]) assert response.status_code == 200 expected = 'Finished refunding basket transactions. [0] transactions were successfully refunded. ' \ diff --git a/ecommerce/settings/_oscar.py b/ecommerce/settings/_oscar.py index cefc568a1c0..cb416ab27e7 100644 --- a/ecommerce/settings/_oscar.py +++ b/ecommerce/settings/_oscar.py @@ -42,7 +42,6 @@ # To prevent issues with Oscar’s dynamic model loading, overrides of dashboard applications should # follow overrides of core applications 'oscar.apps.dashboard.reports', - 'oscar.apps.dashboard.catalogue', 'oscar.apps.dashboard.partners', 'oscar.apps.dashboard.pages', 'oscar.apps.dashboard.ranges', @@ -52,6 +51,7 @@ 'oscar.apps.dashboard.shipping', 'ecommerce.extensions.dashboard', + 'ecommerce.extensions.dashboard.catalogue', 'ecommerce.extensions.dashboard.offers', 'ecommerce.extensions.dashboard.refunds', 'ecommerce.extensions.dashboard.orders', diff --git a/ecommerce/settings/base.py b/ecommerce/settings/base.py index 1f88246d9d4..3c955868888 100644 --- a/ecommerce/settings/base.py +++ b/ecommerce/settings/base.py @@ -223,6 +223,7 @@ 'corsheaders.middleware.CorsMiddleware', 'edx_django_utils.monitoring.DeploymentMonitoringMiddleware', 'edx_django_utils.cache.middleware.RequestCacheMiddleware', + 'edx_django_utils.monitoring.CachedCustomMonitoringMiddleware', 'edx_django_utils.monitoring.CookieMonitoringMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', @@ -309,6 +310,7 @@ 'django_filters', 'release_util', 'crispy_forms', + 'crispy_bootstrap3', 'solo', 'social_django', 'drf_yasg', @@ -445,7 +447,7 @@ 'JWT_AUTH_COOKIE': 'edx-jwt-cookie', 'JWT_VERIFY_EXPIRATION': True, 'JWT_LEEWAY': 1, - 'JWT_DECODE_HANDLER': 'ecommerce.extensions.api.handlers.jwt_decode_handler', + 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.auth.jwt.decoder.jwt_decode_handler', # These settings are NOT part of DRF-JWT's defaults. 'JWT_ISSUERS': [ { @@ -476,6 +478,10 @@ # Worker used by Discovery to consume ecommerce endpoints DISCOVERY_WORKER_USERNAME = 'discovery_worker' +# Worker used by subscriptions to consume ecommerce endpoints + +SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker' + # Used to access the Enrollment API. Set this to the same value used by the LMS. EDX_API_KEY = 'PUT_YOUR_API_KEY_HERE' @@ -662,6 +668,7 @@ # Affiliate cookie key AFFILIATE_COOKIE_KEY = 'affiliate_id' +CRISPY_ALLOWED_TEMPLATE_PACKS = 'bootstrap3' CRISPY_TEMPLATE_PACK = 'bootstrap3' # ENTERPRISE CONFIGURATION @@ -670,7 +677,7 @@ # Cache enterprise response from Enterprise API. ENTERPRISE_API_CACHE_TIMEOUT = 300 # Value is in seconds -ENTERPRISE_CATALOG_SERVICE_URL = 'http://localhost:18160/' +ENTERPRISE_CATALOG_SERVICE_URL = 'http://enterprise.catalog.app:18160/' ENTERPRISE_ANALYTICS_API_URL = 'http://localhost:19001' diff --git a/ecommerce/settings/devstack.py b/ecommerce/settings/devstack.py index 1f953a7dc80..2f430acfb38 100644 --- a/ecommerce/settings/devstack.py +++ b/ecommerce/settings/devstack.py @@ -32,6 +32,9 @@ BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://edx.devstack.lms:18000/oauth2" JWT_AUTH.update({ + # Temporarily set JWT_DECODE_HANDLER until new devstack images are built + # with this updated connfiguration: https://github.com/openedx/configuration/pull/6921. + 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.auth.jwt.decoder.jwt_decode_handler', 'JWT_ISSUER': 'http://localhost:18000/oauth2', 'JWT_ISSUERS': [{ 'AUDIENCE': 'lms-key', @@ -48,12 +51,12 @@ }) CORS_ORIGIN_WHITELIST = ( - 'http://localhost:1991', + 'http://localhost:1991', # Enterprise Admin Portal MFE 'http://localhost:1996', 'http://localhost:1997', # Account MFE 'http://localhost:1998', 'http://localhost:2000', # Learning MFE - 'http://localhost:8734', + 'http://localhost:8734', # Enterprise Learner Portal MFE ) CORS_ALLOW_HEADERS = corsheaders_default_headers + ( 'use-jwt-cookie', diff --git a/ecommerce/settings/local.py b/ecommerce/settings/local.py index e6c940c3216..0e4b2e8ac05 100644 --- a/ecommerce/settings/local.py +++ b/ecommerce/settings/local.py @@ -78,7 +78,7 @@ }) CORS_ORIGIN_WHITELIST = ( - 'http://localhost:1991' + 'http://localhost:1991' # Enterprise Admin Portal MFE ) CORS_ALLOW_HEADERS = corsheaders_default_headers + ( 'use-jwt-cookie', diff --git a/ecommerce/static/js/models/enterprise_coupon_model.js b/ecommerce/static/js/models/enterprise_coupon_model.js index 11492b91f7b..92e9078c057 100644 --- a/ecommerce/static/js/models/enterprise_coupon_model.js +++ b/ecommerce/static/js/models/enterprise_coupon_model.js @@ -33,7 +33,8 @@ define([ contract_discount_type: 'Percentage', contract_discount_value: null, prepaid_invoice_amount: null, - sales_force_id: null + sales_force_id: null, + salesforce_opportunity_line_item: null }, couponValidation: { @@ -56,8 +57,12 @@ define([ pattern: 'number' }, sales_force_id: { - required: true, + required: false, pattern: 'sales_force_id' + }, + salesforce_opportunity_line_item: { + required: true, + pattern: 'salesforce_opportunity_line_item' } }, diff --git a/ecommerce/static/js/test/mock_data/coupons.js b/ecommerce/static/js/test/mock_data/coupons.js index cb3f7276a76..c6c970ae950 100644 --- a/ecommerce/static/js/test/mock_data/coupons.js +++ b/ecommerce/static/js/test/mock_data/coupons.js @@ -263,6 +263,7 @@ define([], function() { contract_discount_value: 30, prepaid_invoice_amount: 10000, sales_force_id: '006ABCDE0123456789', + salesforce_opportunity_line_item: '00kABCDE9876543210', invoice_type: 'Not-Applicable' }, couponAPIResponseData = { diff --git a/ecommerce/static/js/test/specs/models/enterprise_coupon_model_spec.js b/ecommerce/static/js/test/specs/models/enterprise_coupon_model_spec.js index f089b6b05fc..69ca5b89843 100644 --- a/ecommerce/static/js/test/specs/models/enterprise_coupon_model_spec.js +++ b/ecommerce/static/js/test/specs/models/enterprise_coupon_model_spec.js @@ -71,6 +71,24 @@ define([ it('should validate sales_force_id is required', function() { model.set('sales_force_id', ''); model.validate(); + expect(model.isValid()).toBeTruthy(); + }); + + it('should validate salesforce_opportunity_line_item is correct', function() { + model.set('salesforce_opportunity_line_item', 'Invalid_ID'); + model.validate(); + expect(model.isValid()).toBeFalsy(); + }); + + it('should validate salesforce_opportunity_line_item with "none" value', function() { + model.set('salesforce_opportunity_line_item', 'none'); + model.validate(); + expect(model.isValid()).toBeTruthy(); + }); + + it('should validate salesforce_opportunity_line_item is required', function() { + model.set('salesforce_opportunity_line_item', ''); + model.validate(); expect(model.isValid()).toBeFalsy(); }); }); diff --git a/ecommerce/static/js/test/specs/views/enterprise_coupon_create_edit_view_spec.js b/ecommerce/static/js/test/specs/views/enterprise_coupon_create_edit_view_spec.js index b215756aaf3..d0438aebc6f 100644 --- a/ecommerce/static/js/test/specs/views/enterprise_coupon_create_edit_view_spec.js +++ b/ecommerce/static/js/test/specs/views/enterprise_coupon_create_edit_view_spec.js @@ -44,6 +44,7 @@ define([ it('should submit enterprise coupon form with valid fields', function() { view.$('[name=sales_force_id]').val('006ABCDE0123456789').trigger('change'); + view.$('[name=salesforce_opportunity_line_item]').val('00kABCDE9876543210').trigger('change'); view.$('[name=contract_discount_value]').val(30).trigger('change'); view.$('[name=prepaid_invoice_amount]').val(10000).trigger('change'); view.$('[name=title]').val('Test Enrollment').trigger('change'); @@ -113,6 +114,9 @@ define([ expect(view.$el.find('[name=sales_force_id]').val()).toEqual( model.get('sales_force_id') ); + expect(view.$el.find('[name=salesforce_opportunity_line_item]').val()).toEqual( + model.get('salesforce_opportunity_line_item') + ); expect(view.$el.find('[name=contract_discount_value]').val()).toEqual( model.get('contract_discount_value').toString() ); diff --git a/ecommerce/static/js/utils/validation_patterns.js b/ecommerce/static/js/utils/validation_patterns.js index 7f988af5806..e32bfaad9b3 100644 --- a/ecommerce/static/js/utils/validation_patterns.js +++ b/ecommerce/static/js/utils/validation_patterns.js @@ -33,5 +33,15 @@ define([ _.extend(Backbone.Validation.messages, { sales_force_id: gettext('Salesforce Opportunity ID must be 18 alphanumeric characters and begin with 006') }); + + _.extend(Backbone.Validation.patterns, { + salesforce_opportunity_line_item: /^00k[a-zA-Z0-9]{15}$|^none$/ + }); + + _.extend(Backbone.Validation.messages, { + salesforce_opportunity_line_item: gettext( + 'Salesforce Opportunity Line Item must be 18 alphanumeric characters and begin with \'00k\'' + ) + }); } ); diff --git a/ecommerce/static/js/views/coupon_form_view.js b/ecommerce/static/js/views/coupon_form_view.js index 07d4d78f527..dcd71cc6f76 100644 --- a/ecommerce/static/js/views/coupon_form_view.js +++ b/ecommerce/static/js/views/coupon_form_view.js @@ -214,6 +214,12 @@ define([ onSet: function(val) { return val === '' ? null : val; } + }, + 'input[name=salesforce_opportunity_line_item]': { + observe: 'salesforce_opportunity_line_item', + onSet: function(val) { + return val === '' ? null : val; + } } }, diff --git a/ecommerce/static/js/views/enterprise_coupon_form_view.js b/ecommerce/static/js/views/enterprise_coupon_form_view.js index 6109ff65802..73d014cb123 100644 --- a/ecommerce/static/js/views/enterprise_coupon_form_view.js +++ b/ecommerce/static/js/views/enterprise_coupon_form_view.js @@ -252,7 +252,8 @@ define([ 'contract_discount_value', 'contract_discount_type', 'prepaid_invoice_amount', - 'sales_force_id' + 'sales_force_id', + 'salesforce_opportunity_line_item' ]; }, diff --git a/ecommerce/static/templates/enterprise_coupon_form.html b/ecommerce/static/templates/enterprise_coupon_form.html index 43c0722c0e8..a98372c2da3 100644 --- a/ecommerce/static/templates/enterprise_coupon_form.html +++ b/ecommerce/static/templates/enterprise_coupon_form.html @@ -1,233 +1,337 @@
<% if(editing) {%> -
-
- -
-
- - -
-
- - +
+
+ +
+
+ + +
+
+ + +
-
- <%}%> -
-
- - -

-
-
- - -

-
-
- - -

-
- -
-
- -
-
- -
-

-
-
- -
-
- + <%}%> +
+
+ + +

+
+
+ + +

+
+
+ + +

-

-
-
-
- - -

-
+
+
+ +
+
+ +
+

+
+
+ +
+
+ +
+

+
+
-
- - -

-
+
+ + +

+
-
- - -

-
+
+ + +

+
-
- - -

-
+
+ + +

+
-
- -
-
- -
-
- - - - -
-

-
+
+ + +

+
-
- -
-
- -
-
+
+ +
+
+ +
+
+ + + + +
+

+
-
- -
-
- -
-
- - - - -
-

-
-
+
+ +
+
+ +
+
-
- -
-
- - +
+ +
+
+ +
+
+ + + + +
+

-
- - +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
-
- - + +
+ + +

-
-
-
- - -

-
+
+ +
+
$
+ +
+

+
-
- -
-
$
- -
-

-
+
+ +
+
+ +
+

+
-
- -
-
- -
-

-
+ - +
+ +
+ + + + +
+
-
- -
- - - - +
-
- - -
-
- - -

-
-
- - - Enterprise Catalog Details -

-
- -
- - -

-
-
- - -

-
-
- - -

-
-
diff --git a/ecommerce/tests/mixins.py b/ecommerce/tests/mixins.py index 01c83aacbf1..a03154b06d6 100644 --- a/ecommerce/tests/mixins.py +++ b/ecommerce/tests/mixins.py @@ -5,8 +5,8 @@ import datetime import json from decimal import Decimal +from unittest.mock import Mock -import jwt import responses from crum import set_current_request from django.conf import settings @@ -17,7 +17,11 @@ from edx_django_utils.cache import TieredCache from edx_rest_api_client.client import _get_oauth_url from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name -from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt_token, generate_unversioned_payload +from edx_rest_framework_extensions.auth.jwt.tests.utils import ( + generate_jwt, + generate_jwt_token, + generate_unversioned_payload +) from mock import patch from oscar.core.loading import get_class, get_model from oscar.test import factories @@ -82,26 +86,9 @@ def set_user_id_in_social_auth(self, user, user_id=None): user_id = user_id or self.user_id UserSocialAuth.objects.create(user=user, extra_data={'user_id': user_id}) - def generate_jwt_token_header(self, user, secret=None): + def generate_jwt_token_header(self, user): """Generate a valid JWT token header for authenticated requests.""" - secret = secret or settings.JWT_AUTH['JWT_SECRET_KEY'] - - # WARNING: - # If any test that uses this function fails with an error about a missing 'exp' or 'iat' or - # 'is_restricted' claim in the payload, then do one of the following: - # - # 1. If Ecommerce's JWT_DECODE_HANDLER setting still points to a custom decoder inside Ecommerce, - # then a bug was introduced and the setting is no longer respected. If this is the case, do not - # add the claims to this test, and instead fix the bug. Or, - # 2. If Ecommerce is being updated to no longer use a custom JWT_DECODE_HANDLER from Ecommerce, but is - # instead using the decode handler directly from edx-drf-extensions, any required claims can be - # added to this test and this warning can be removed. - payload = { - 'username': user.username, - 'email': user.email, - 'iss': settings.JWT_AUTH['JWT_ISSUERS'][0]['ISSUER'] - } - return "JWT {token}".format(token=jwt.encode(payload, secret).decode('utf-8')) + return "JWT {token}".format(token=generate_jwt(user)) class ThrottlingMixin: @@ -116,14 +103,13 @@ def setUp(self): class JwtMixin: """ Mixin with JWT-related helper functions. """ - JWT_SECRET_KEY = settings.JWT_AUTH['JWT_SECRET_KEY'] + JWT_SECRET_KEY = settings.JWT_AUTH['JWT_ISSUERS'][0]['SECRET_KEY'] issuer = settings.JWT_AUTH['JWT_ISSUERS'][0]['ISSUER'] def generate_token(self, payload, secret=None): """Generate a JWT token with the provided payload.""" secret = secret or self.JWT_SECRET_KEY - token = jwt.encode(dict(payload, iss=self.issuer), secret).decode('utf-8') - return token + return generate_jwt_token(payload, secret) def set_jwt_cookie(self, system_wide_role=SYSTEM_ENTERPRISE_ADMIN_ROLE, context='some_context'): """ @@ -141,6 +127,26 @@ def set_jwt_cookie(self, system_wide_role=SYSTEM_ENTERPRISE_ADMIN_ROLE, context= self.client.cookies[jwt_cookie_name()] = jwt_token + def generate_new_user_token(self, username, email, is_staff): + """ + Generates a JWT token for a user with provided attributes. + + Note: Doesn't actually create the user object, so it can be created + during JWT authentication. + """ + # create a mock user, and not the actual user, because we want to confirm that + # the user is created during JWT authentication + user = Mock() + user.username = username + user.email = email + user.is_staff = is_staff + + payload = generate_unversioned_payload(user) + # At this time, generate_unversioned_payload isn't setting 'administrator', but + # it should. + payload['administrator'] = is_staff + return self.generate_token(payload) + class BasketCreationMixin(UserMixin, JwtMixin): """Provides utility methods for creating baskets in test cases.""" @@ -209,7 +215,7 @@ def assert_successful_basket_creation( # Ideally, we'd use Oscar's ShippingEventTypeFactory here, but it's not exposed/public. ShippingEventType.objects.get_or_create(name=SHIPPING_EVENT_NAME) - with patch('ecommerce.extensions.analytics.utils.audit_log') as mock_audit_log: + with patch('ecommerce.extensions.api.v2.views.baskets.audit_log') as mock_audit_log: response = self.create_basket(skus=skus, checkout=checkout, payment_processor_name=payment_processor_name) self.assertEqual(response.status_code, 200) @@ -219,13 +225,13 @@ def assert_successful_basket_creation( self.assertEqual(response.data['id'], basket.id) if checkout: - self.assertTrue(mock_audit_log.called_with( + mock_audit_log.assert_called_with( 'basket_frozen', amount=basket.total_excl_tax, basket_id=basket.id, currency=basket.currency, user_id=basket.owner.id - )) + ) if requires_payment: self.assertIsNone(response.data['order']) diff --git a/requirements/base.in b/requirements/base.in index 7eefe874a30..e0b2cb104ad 100755 --- a/requirements/base.in +++ b/requirements/base.in @@ -1,9 +1,11 @@ -c constraints.txt analytics-python +app-store-notifications-v2-validator==0.0.7 bleach boto3>=1.17.80 coreapi +crispy-bootstrap3 cybersource-rest-client-python django>=3.2,<4.0 django-compressor diff --git a/requirements/base.txt b/requirements/base.txt index 07ec08b86b3..6bec622bc53 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,111 +1,117 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -aiohttp==3.8.0 +aiohttp==3.8.4 # via inapppy +aiosignal==1.3.1 + # via aiohttp amqp==2.6.1 # via kombu -analytics-python==1.4.0 +analytics-python==1.4.post1 + # via -r requirements/base.in +app-store-notifications-v2-validator==0.0.7 # via -r requirements/base.in -asgiref==3.5.2 +asgiref==3.7.2 # via django asn1crypto==1.5.1 # via cybersource-rest-client-python async-timeout==4.0.2 - # via redis -attrs==22.1.0 + # via + # aiohttp + # redis +attrs==23.1.0 # via # aiohttp # jsonschema # zeep -babel==2.10.3 +babel==2.12.1 # via django-oscar backoff==1.10.0 # via analytics-python -bcrypt==4.0.0 +bcrypt==4.0.1 # via # cybersource-rest-client-python # paramiko billiard==3.6.4.0 # via celery -bleach==5.0.1 +bleach==6.0.0 # via -r requirements/base.in -boto3==1.24.73 +boto3==1.26.155 # via -r requirements/base.in -botocore==1.27.73 +botocore==1.29.155 # via # boto3 # s3transfer -cached-property==1.5.2 - # via zeep -cachetools==4.2.2 +cachetools==5.3.1 # via google-auth celery==4.4.7 # via # -c requirements/constraints.txt # edx-ecommerce-worker -certifi==2022.9.14 +certifi==2023.5.7 # via # cybersource-rest-client-python # requests cffi==1.15.1 # via + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl -chardet==5.0.0 +chardet==5.1.0 # via cybersource-rest-client-python -charset-normalizer==2.1.1 - # via requests +charset-normalizer==3.1.0 + # via + # aiohttp + # requests click==8.1.3 # via edx-django-utils configparser==5.3.0 # via cybersource-rest-client-python coreapi==2.3.3 - # via - # -r requirements/base.in - # drf-yasg + # via -r requirements/base.in coreschema==0.0.4 - # via - # coreapi - # drf-yasg -coverage==6.4.4 + # via coreapi +coverage==7.2.7 # via cybersource-rest-client-python +crispy-bootstrap3==2022.1 + # via -r requirements/base.in crypto==1.4.1 # via cybersource-rest-client-python -cryptography==38.0.1 +cryptography==41.0.1 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt # pyopenssl # social-auth-core -cssselect==1.1.0 +cssselect==1.2.0 # via premailer -cssutils==2.6.0 +cssutils==2.7.1 # via premailer cybersource-rest-client-python==0.0.21 # via # -c requirements/constraints.txt # -r requirements/base.in -datetime==4.7 +datetime==5.1 # via cybersource-rest-client-python defusedxml==0.7.1 # via # python3-openid # social-auth-core -deprecated==1.2.13 - # via redis -django==3.2.15 +django==3.2.20 # via # -c requirements/common_constraints.txt # -r requirements/base.in + # crispy-bootstrap3 # django-appconf # django-config-models # django-cors-headers + # django-crispy-forms # django-crum # django-extensions # django-extra-views @@ -127,35 +133,38 @@ django==3.2.15 # edx-drf-extensions # edx-rbac # jsonfield - # rest-condition + # jsonfield2 + # social-auth-app-django # xss-utils django-appconf==1.0.5 # via django-compressor -django-compressor==4.1 +django-compressor==4.3.1 # via # -r requirements/base.in # django-libsass django-config-models==2.3.0 # via -r requirements/base.in -django-cors-headers==3.13.0 - # via -r requirements/base.in -django-crispy-forms==1.14.0 +django-cors-headers==4.1.0 # via -r requirements/base.in +django-crispy-forms==2.0 + # via + # -r requirements/base.in + # crispy-bootstrap3 django-crum==0.7.9 # via # edx-django-utils # edx-rbac -django-extensions==3.2.1 +django-extensions==3.2.3 # via -r requirements/base.in django-extra-views==0.13.0 # via django-oscar -django-filter==22.1 +django-filter==23.2 # via -r requirements/base.in -django-haystack==3.1.1 +django-haystack==3.2.1 # via django-oscar django-libsass==0.9 # via -r requirements/base.in -django-model-utils==4.2.0 +django-model-utils==4.3.1 # via edx-rbac django-oscar==2.2 # via @@ -167,7 +176,7 @@ django-simple-history==3.0.0 # via # -c requirements/common_constraints.txt # -r requirements/base.in -django-solo==2.0.0 +django-solo==2.1.0 # via -r requirements/base.in django-tables2==2.4.1 # via django-oscar @@ -182,7 +191,7 @@ django-waffle==3.0.0 # edx-drf-extensions django-widget-tweaks==1.4.12 # via django-oscar -djangorestframework==3.13.1 +djangorestframework==3.14.0 # via # -r requirements/base.in # django-config-models @@ -192,7 +201,6 @@ djangorestframework==3.13.1 # drf-jwt # drf-yasg # edx-drf-extensions - # rest-condition djangorestframework-csv==2.1.1 # via -r requirements/base.in djangorestframework-datatables==0.7.0 @@ -201,33 +209,28 @@ drf-extensions==0.7.1 # via -r requirements/base.in drf-jwt==1.19.2 # via edx-drf-extensions -drf-yasg==1.20.0 - # via - # -c requirements/constraints.txt - # -r requirements/base.in -edx-auth-backends==3.4.0 - # via - # -c requirements/constraints.txt - # -r requirements/base.in -edx-braze-client==0.1.4 +drf-yasg==1.21.6 + # via -r requirements/base.in +edx-auth-backends==4.1.0 + # via -r requirements/base.in +edx-braze-client==0.1.6 # via edx-ecommerce-worker edx-django-release-util==1.2.0 # via -r requirements/base.in edx-django-sites-extensions==4.0.0 # via -r requirements/base.in -edx-django-utils==5.0.1 +edx-django-utils==5.5.0 # via # -r requirements/base.in # django-config-models # edx-drf-extensions # edx-rest-api-client # getsmarter-api-clients -edx-drf-extensions==6.6.0 +edx-drf-extensions==8.8.0 # via - # -c requirements/constraints.txt # -r requirements/base.in # edx-rbac -edx-ecommerce-worker==3.3.2 +edx-ecommerce-worker==3.3.4 # via -r requirements/base.in edx-opaque-keys==2.3.0 # via @@ -235,7 +238,7 @@ edx-opaque-keys==2.3.0 # edx-drf-extensions edx-rbac==1.7.0 # via -r requirements/base.in -edx-rest-api-client==5.5.0 +edx-rest-api-client==5.5.2 # via # -r requirements/base.in # edx-ecommerce-worker @@ -245,48 +248,55 @@ extras==1.0.0 # via # cybersource-rest-client-python # python-subunit - # testtools factory-boy==2.12.0 # via django-oscar -faker==14.2.0 +faker==18.10.1 # via factory-boy -fixtures==3.0.0 +fixtures==4.1.0 # via # cybersource-rest-client-python # testtools +frozenlist==1.3.3 + # via + # aiohttp + # aiosignal funcsigs==1.0.2 # via cybersource-rest-client-python -future==0.18.2 - # via pyjwkest -getsmarter-api-clients==0.4.0 +getsmarter-api-clients==0.6.0 # via -r requirements/base.in -google-api-core==1.30.0 +google-api-core==2.11.1 # via google-api-python-client google-api-python-client==2.31.0 - # via -r requirements/base.in -google-auth-httplib2==0.1.0 - # via google-api-python-client -google-auth==1.32.1 + # via + # -r requirements/base.in + # inapppy +google-auth==2.20.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 -googleapis-common-protos==1.53.0 +google-auth-httplib2==0.1.0 + # via google-api-python-client +googleapis-common-protos==1.59.1 # via google-api-core httplib2==0.20.2 - # via -r requirements/base.in + # via + # -r requirements/base.in + # google-api-python-client + # google-auth-httplib2 + # oauth2client idna==2.7 # via # -c requirements/constraints.txt # cybersource-rest-client-python # requests -importlib-resources==5.9.0 - # via jsonschema -inflection==0.5.1 - # via drf-yasg # yarl +importlib-resources==5.12.0 + # via jsonschema inapppy==2.5.2 # via -r requirements/base.in +inflection==0.5.1 + # via drf-yasg ipaddress==1.0.23 # via cybersource-rest-client-python isodate==0.6.1 @@ -301,7 +311,9 @@ jmespath==1.0.1 # botocore jsonfield==3.1.0 # via -r requirements/base.in -jsonschema==4.16.0 +jsonfield2==4.0.0.post0 + # via -r requirements/base.in +jsonschema==4.17.3 # via cybersource-rest-client-python kombu==4.6.11 # via celery @@ -315,29 +327,29 @@ linecache2==1.0.0 # traceback2 logger==1.4 # via cybersource-rest-client-python -lxml==4.9.1 +lxml==4.9.2 # via # premailer # zeep markdown==2.6.9 # via -r requirements/base.in -markupsafe==2.1.1 +markupsafe==2.1.3 # via jinja2 monotonic==1.6 # via analytics-python -multidict==5.1.0 +multidict==6.0.4 # via # aiohttp # yarl mysqlclient==1.4.6 # via -r requirements/base.in -naked==0.1.31 +naked==0.1.32 # via # crypto # cybersource-rest-client-python ndg-httpsclient==0.5.1 # via -r requirements/base.in -newrelic==8.1.0.180 +newrelic==8.8.0 # via # -r requirements/base.in # edx-django-utils @@ -345,52 +357,44 @@ nose==1.3.7 # via cybersource-rest-client-python oauth2client==4.1.3 # via inapppy -oauthlib==3.2.1 +oauthlib==3.2.2 # via # getsmarter-api-clients # requests-oauthlib # social-auth-core -packaging==21.3 - # via - # drf-yasg - # redis -paramiko==2.11.0 +packaging==23.1 + # via drf-yasg +paramiko==3.2.0 # via cybersource-rest-client-python -openapi-codec==1.3.2 - # via django-rest-swagger path-py==7.2 # via -r requirements/base.in paypalrestsdk==1.13.1 # via -r requirements/base.in -pbr==5.10.0 +pbr==5.11.1 # via # cybersource-rest-client-python # fixtures # stevedore # testtools -phonenumbers==8.12.55 +phonenumbers==8.13.14 # via django-oscar -pillow==9.2.0 +pillow==9.5.0 # via django-oscar pkgutil-resolve-name==1.3.10 # via jsonschema -platformdirs==2.5.2 +platformdirs==3.6.0 # via zeep premailer==2.9.2 # via -r requirements/base.in -protobuf==3.17.3 +protobuf==4.23.3 # via # google-api-core # googleapis-common-protos -psutil==5.9.2 +psutil==5.9.5 # via edx-django-utils purl==1.6 # via django-oscar -pyasn1-modules==0.2.8 - # via - # google-auth - # oauth2client -pyasn1==0.4.8 +pyasn1==0.5.0 # via # cybersource-rest-client-python # ndg-httpsclient @@ -398,46 +402,50 @@ pyasn1==0.4.8 # pyasn1-modules # rsa # x509 +pyasn1-modules==0.3.0 + # via + # google-auth + # oauth2client pycountry==17.1.8 # via -r requirements/base.in pycparser==2.21 # via + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python -pycryptodome==3.15.0 +pycryptodome==3.18.0 # via cybersource-rest-client-python -pycryptodomex==3.15.0 - # via - # cybersource-rest-client-python - # pyjwkest -pygments==2.13.0 +pycryptodomex==3.18.0 + # via cybersource-rest-client-python +pygments==2.15.1 # via -r requirements/base.in -pyjwkest==1.4.2 - # via edx-drf-extensions -pyjwt[crypto]==1.7.1 +pyjwt[crypto]==2.7.0 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends + # edx-drf-extensions # edx-rest-api-client # social-auth-core -pymongo==3.12.3 +pymongo==3.13.0 # via edx-opaque-keys pynacl==1.5.0 # via # cybersource-rest-client-python # edx-django-utils # paramiko -pyopenssl==22.0.0 +pyopenssl==23.2.0 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk -pyparsing==3.0.9 - # via packaging +pyparsing==3.1.0 + # via httplib2 pypi==2.1 # via cybersource-rest-client-python -pyrsistent==0.18.1 +pyrsistent==0.19.3 # via jsonschema python-dateutil==2.8.2 # via @@ -448,7 +456,7 @@ python-dateutil==2.8.2 # faker python-mimeparse==1.6.0 # via cybersource-rest-client-python -python-subunit==1.4.0 +python-subunit==1.4.2 # via cybersource-rest-client-python python-toolbox==1.0.11 # via cybersource-rest-client-python @@ -456,9 +464,8 @@ python3-openid==3.2.0 # via # -r requirements/base.in # social-auth-core -pytz==2016.10 +pytz==2023.3 # via - # -c requirements/constraints.txt # -r requirements/base.in # babel # celery @@ -467,19 +474,20 @@ pytz==2016.10 # django # djangorestframework # djangorestframework-datatables + # drf-yasg # getsmarter-api-clients - # google-api-core # zeep pyyaml==6.0 # via # cybersource-rest-client-python + # drf-yasg # edx-django-release-util # naked -rcssmin==1.1.0 +rcssmin==1.1.1 # via django-compressor -redis==4.3.4 +redis==4.5.5 # via edx-ecommerce-worker -requests==2.28.1 +requests==2.31.0 # via # -r requirements/base.in # analytics-python @@ -491,7 +499,6 @@ requests==2.28.1 # inapppy # naked # paypalrestsdk - # pyjwkest # requests-file # requests-oauthlib # requests-toolbelt @@ -505,21 +512,19 @@ requests-oauthlib==1.3.1 # via # getsmarter-api-clients # social-auth-core -requests-toolbelt==0.9.1 +requests-toolbelt==1.0.0 # via zeep -rest-condition==1.0.3 - # via edx-drf-extensions -rjsmin==1.2.0 +rjsmin==1.2.1 # via django-compressor rsa==4.9 - # via cybersource-rest-client-python -ruamel-yaml==0.17.21 - # via drf-yasg -ruamel-yaml-clib==0.2.6 - # via ruamel-yaml + # via + # cybersource-rest-client-python + # google-auth + # inapppy + # oauth2client rules==3.3 # via -r requirements/base.in -s3transfer==0.6.0 +s3transfer==0.6.1 # via boto3 semantic-version==2.10.0 # via edx-drf-extensions @@ -527,7 +532,7 @@ shellescape==3.8.1 # via # crypto # cybersource-rest-client-python -simplejson==3.17.6 +simplejson==3.19.1 # via -r requirements/base.in six==1.16.0 # via @@ -541,55 +546,45 @@ six==1.16.0 # edx-drf-extensions # edx-ecommerce-worker # edx-rbac - # fixtures - # google-api-core - # google-api-python-client # google-auth # google-auth-httplib2 # isodate # libsass # oauth2client - # paramiko # paypalrestsdk - # protobuf # purl - # pyjwkest # python-dateutil # requests-file - # social-auth-app-django - # social-auth-core slumber==0.7.1 # via edx-rest-api-client -social-auth-app-django==4.0.0 +social-auth-app-django==5.2.0 # via - # -c requirements/constraints.txt # -r requirements/base.in # edx-auth-backends -social-auth-core==4.0.2 +social-auth-core==4.4.2 # via - # -c requirements/constraints.txt # edx-auth-backends # social-auth-app-django sorl-thumbnail==12.9.0 # via -r requirements/base.in -sqlparse==0.4.2 +sqlparse==0.4.4 # via django -stevedore==4.0.0 +stevedore==5.1.0 # via # edx-django-utils # edx-opaque-keys -stripe==4.1.0 +stripe==5.4.0 # via -r requirements/base.in -testtools==2.5.0 +testtools==2.6.0 # via # cybersource-rest-client-python # python-subunit traceback2==1.4.0 # via cybersource-rest-client-python -typing-extensions==4.0.1 - # via aiohttp typing==3.7.4.3 # via cybersource-rest-client-python +typing-extensions==4.6.3 + # via asgiref unicodecsv==0.14.1 # via # -r requirements/base.in @@ -598,13 +593,13 @@ uritemplate==4.1.1 # via # coreapi # drf-yasg -unittest2==1.1.0 - # via testtools -urllib3==1.26.12 + # google-api-python-client +urllib3==1.26.16 # via # -c requirements/constraints.txt # botocore # cybersource-rest-client-python + # google-auth # requests vine==1.3.0 # via @@ -612,23 +607,19 @@ vine==1.3.0 # celery webencodings==0.5.1 # via bleach -wheel==0.37.1 +wheel==0.40.0 # via cybersource-rest-client-python -wrapt==1.13.3 - # via - # -c requirements/constraints.txt - # deprecated x509==0.1 # via cybersource-rest-client-python xss-utils==0.4.0 # via -r requirements/base.in -yarl==1.6.3 +yarl==1.9.2 # via aiohttp -zeep==4.1.0 +zeep==4.2.1 # via -r requirements/base.in -zipp==3.8.1 +zipp==3.15.0 # via importlib-resources -zope-interface==5.4.0 +zope-interface==6.0 # via # cybersource-rest-client-python # datetime diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index b1dfdf0176d..7e39123ff04 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -19,9 +19,14 @@ Django<4.0 # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html elasticsearch<7.14.0 -# setuptools==60.0 had breaking changes and busted several service's pipeline. -# Details can be found here: https://github.com/pypa/setuptools/issues/2940 -setuptools<60 - # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected django-simple-history==3.0.0 + +# tox>4.0.0 isn't yet compatible with many tox plugins, causing CI failures in almost all repos. +# Details can be found in this discussion: https://github.com/tox-dev/tox/discussions/1810 +tox<4.0.0 + +# edx-sphinx-theme is not compatible with latest Sphinx==6.0.0 version +# Pinning Sphinx version unless the compatibility issue gets resolved +# For details, see issue https://github.com/openedx/edx-sphinx-theme/issues/197 +sphinx<6.0.0 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 094b00c6dea..b8fd8fd9bc9 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -17,11 +17,6 @@ cybersource-rest-client-python==0.0.21 # Django 3.2 support is added in version 2.2 so pinning it to 2.2 django-oscar==2.2 -# Pinned to preserve the status quo -pytz==2016.10 -# drf-yasg>=1.20.3 requires pytz>2021.10 so pinning the version untill pytz is upgraded -drf-yasg<1.20.3 - # Pinned because transifex-client==0.13.6 pins it urllib3>=1.24.2,<2.0.0 @@ -38,17 +33,15 @@ idna==2.7 # TODO : Pinning this until we are sure there aren't any breaking changes, then we'll upgrade. celery<5.0.0 -# Latest version requires PyJWT>=2.0.0 but drf-jwt requires PyJWT[crypto]<2.0.0,>=1.5.2 -social-auth-core<4.0.3 - -# Versions higher than this need new PyJWT 2.1.0 -# pinning these to unstick 'make upgrade' until we're ready to upgrade PyJWT -edx-drf-extensions<7.0.0 -edx-auth-backends<4.0.0 -social-auth-app-django<5.0.0 # version 5.0.0 requires social-auth-core>=4.1.0 - -# bok-choy 1.1.1 requires <4 (can remove once we have a version without that requirement) +# bok-choy 2.0.1 still requires selenium<4 +# (bok-choy is now deprecated; updates unlikely) +# - pytest-selenium v3 has inconsistent pytest dependency requirements +# (see pytest-selenium/issues/294) +# - pytest-variables v3 uses pytest.stash instead of _variables. This +# conflicts with how pytest-selenium uses variables prior to v3. selenium<4.0.0 +pytest-selenium<3.0.0 +pytest-variables<3.0.0 # pylint>2.12.2 requires a lot of quality fixes. Can be resolved later on. pylint==2.12.2 diff --git a/requirements/dev.txt b/requirements/dev.txt index cebcdeddd8b..d2c0dbb9b0a 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -1,14 +1,22 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -aiohttp==3.8.0 +accessible-pygments==0.0.4 + # via + # -r requirements/docs.txt + # pydata-sphinx-theme +aiohttp==3.8.4 # via # -r requirements/test.txt # inapppy -alabaster==0.7.12 +aiosignal==1.3.1 + # via + # -r requirements/test.txt + # aiohttp +alabaster==0.7.13 # via # -r requirements/docs.txt # sphinx @@ -16,9 +24,11 @@ amqp==2.6.1 # via # -r requirements/test.txt # kombu -analytics-python==1.4.0 +analytics-python==1.4.post1 + # via -r requirements/test.txt +app-store-notifications-v2-validator==0.0.7 # via -r requirements/test.txt -asgiref==3.5.2 +asgiref==3.7.2 # via # -r requirements/test.txt # django @@ -33,53 +43,52 @@ astroid==2.9.3 async-timeout==4.0.2 # via # -r requirements/test.txt + # aiohttp # redis -attrs==22.1.0 +attrs==23.1.0 # via # -r requirements/test.txt # aiohttp # jsonschema - # pytest # zeep -babel==2.10.3 +babel==2.12.1 # via # -r requirements/docs.txt # -r requirements/test.txt # django-oscar + # pydata-sphinx-theme # sphinx backoff==1.10.0 # via # -r requirements/test.txt # analytics-python -bcrypt==4.0.0 +bcrypt==4.0.1 # via # -r requirements/test.txt # cybersource-rest-client-python # paramiko -beautifulsoup4==4.11.1 +beautifulsoup4==4.12.2 # via + # -r requirements/docs.txt # -r requirements/test.txt + # pydata-sphinx-theme # webtest billiard==3.6.4.0 # via # -r requirements/test.txt # celery -bleach==5.0.1 +bleach==6.0.0 # via -r requirements/test.txt -bok-choy==1.1.1 +bok-choy==2.0.2 # via -r requirements/test.txt -boto3==1.24.73 +boto3==1.26.155 # via -r requirements/test.txt -botocore==1.27.73 +botocore==1.29.155 # via # -r requirements/test.txt # boto3 # s3transfer -cached-property==1.5.2 - # via - # -r requirements/test.txt - # zeep -cachetools==4.2.2 +cachetools==5.3.1 # via # -r requirements/test.txt # google-auth @@ -87,7 +96,7 @@ celery==4.4.7 # via # -r requirements/test.txt # edx-ecommerce-worker -certifi==2022.9.14 +certifi==2023.5.7 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -96,19 +105,20 @@ certifi==2022.9.14 cffi==1.15.1 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl -chardet==5.0.0 +chardet==5.1.0 # via # -r requirements/test.txt - # aiohttp # cybersource-rest-client-python # diff-cover -charset-normalizer==2.1.1 +charset-normalizer==3.1.0 # via # -r requirements/docs.txt # -r requirements/test.txt + # aiohttp # requests click==8.1.3 # via @@ -119,42 +129,42 @@ configparser==5.3.0 # -r requirements/test.txt # cybersource-rest-client-python coreapi==2.3.3 - # via - # -r requirements/test.txt - # drf-yasg + # via -r requirements/test.txt coreschema==0.0.4 # via # -r requirements/test.txt # coreapi - # drf-yasg -coverage[toml]==6.4.4 +coverage[toml]==7.2.7 # via # -r requirements/test.txt # cybersource-rest-client-python # pytest-cov +crispy-bootstrap3==2022.1 + # via -r requirements/test.txt crypto==1.4.1 # via # -r requirements/test.txt # cybersource-rest-client-python -cryptography==38.0.1 +cryptography==41.0.1 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt # pyopenssl # social-auth-core -cssselect==1.1.0 +cssselect==1.2.0 # via # -r requirements/test.txt # premailer -cssutils==2.6.0 +cssutils==2.7.1 # via # -r requirements/test.txt # premailer cybersource-rest-client-python==0.0.21 # via -r requirements/test.txt -datetime==4.7 +datetime==5.1 # via # -r requirements/test.txt # cybersource-rest-client-python @@ -165,18 +175,16 @@ defusedxml==0.7.1 # -r requirements/test.txt # python3-openid # social-auth-core -deprecated==1.2.13 - # via - # -r requirements/test.txt - # redis -diff-cover==6.5.1 +diff-cover==7.6.0 # via -r requirements/test.txt -django==3.2.15 +django==3.2.20 # via # -r requirements/test.txt + # crispy-bootstrap3 # django-appconf # django-config-models # django-cors-headers + # django-crispy-forms # django-crum # django-debug-toolbar # django-extensions @@ -200,44 +208,47 @@ django==3.2.15 # edx-i18n-tools # edx-rbac # jsonfield - # rest-condition + # jsonfield2 + # social-auth-app-django # xss-utils django-appconf==1.0.5 # via # -r requirements/test.txt # django-compressor -django-compressor==4.1 +django-compressor==4.3.1 # via # -r requirements/test.txt # django-libsass django-config-models==2.3.0 # via -r requirements/test.txt -django-cors-headers==3.13.0 - # via -r requirements/test.txt -django-crispy-forms==1.14.0 +django-cors-headers==4.1.0 # via -r requirements/test.txt +django-crispy-forms==2.0 + # via + # -r requirements/test.txt + # crispy-bootstrap3 django-crum==0.7.9 # via # -r requirements/test.txt # edx-django-utils # edx-rbac -django-debug-toolbar==3.6.0 +django-debug-toolbar==4.1.0 # via -r requirements/dev.in -django-extensions==3.2.1 +django-extensions==3.2.3 # via -r requirements/test.txt django-extra-views==0.13.0 # via # -r requirements/test.txt # django-oscar -django-filter==22.1 +django-filter==23.2 # via -r requirements/test.txt -django-haystack==3.1.1 +django-haystack==3.2.1 # via # -r requirements/test.txt # django-oscar django-libsass==0.9 # via -r requirements/test.txt -django-model-utils==4.2.0 +django-model-utils==4.3.1 # via # -r requirements/test.txt # edx-rbac @@ -249,7 +260,7 @@ django-phonenumber-field==5.0.0 # django-oscar django-simple-history==3.0.0 # via -r requirements/test.txt -django-solo==2.0.0 +django-solo==2.1.0 # via -r requirements/test.txt django-tables2==2.4.1 # via @@ -272,7 +283,7 @@ django-widget-tweaks==1.4.12 # via # -r requirements/test.txt # django-oscar -djangorestframework==3.13.1 +djangorestframework==3.14.0 # via # -r requirements/test.txt # django-config-models @@ -282,7 +293,6 @@ djangorestframework==3.13.1 # drf-jwt # drf-yasg # edx-drf-extensions - # rest-condition djangorestframework-csv==2.1.1 # via -r requirements/test.txt djangorestframework-datatables==0.7.0 @@ -290,6 +300,7 @@ djangorestframework-datatables==0.7.0 docutils==0.19 # via # -r requirements/docs.txt + # pydata-sphinx-theme # sphinx drf-extensions==0.7.1 # via -r requirements/test.txt @@ -297,11 +308,11 @@ drf-jwt==1.19.2 # via # -r requirements/test.txt # edx-drf-extensions -drf-yasg==1.20.0 +drf-yasg==1.21.6 # via -r requirements/test.txt -edx-auth-backends==3.4.0 +edx-auth-backends==4.1.0 # via -r requirements/test.txt -edx-braze-client==0.1.4 +edx-braze-client==0.1.6 # via # -r requirements/test.txt # edx-ecommerce-worker @@ -309,20 +320,20 @@ edx-django-release-util==1.2.0 # via -r requirements/test.txt edx-django-sites-extensions==4.0.0 # via -r requirements/test.txt -edx-django-utils==5.0.1 +edx-django-utils==5.5.0 # via # -r requirements/test.txt # django-config-models # edx-drf-extensions # edx-rest-api-client # getsmarter-api-clients -edx-drf-extensions==6.6.0 +edx-drf-extensions==8.8.0 # via # -r requirements/test.txt # edx-rbac -edx-ecommerce-worker==3.3.2 +edx-ecommerce-worker==3.3.4 # via -r requirements/test.txt -edx-i18n-tools==0.9.1 +edx-i18n-tools==0.9.2 # via -r requirements/test.txt edx-opaque-keys==2.3.0 # via @@ -330,96 +341,107 @@ edx-opaque-keys==2.3.0 # edx-drf-extensions edx-rbac==1.7.0 # via -r requirements/test.txt -edx-rest-api-client==5.5.0 +edx-rest-api-client==5.5.2 # via # -r requirements/test.txt # edx-ecommerce-worker -edx-sphinx-theme==3.0.0 - # via -r requirements/docs.txt enum34==1.1.10 # via # -r requirements/test.txt # cybersource-rest-client-python +exceptiongroup==1.1.1 + # via + # -r requirements/test.txt + # pytest extras==1.0.0 # via # -r requirements/test.txt # cybersource-rest-client-python # python-subunit - # testtools factory-boy==2.12.0 # via # -r requirements/test.txt # django-oscar -faker==14.2.0 +faker==18.10.1 # via # -r requirements/test.txt # factory-boy -filelock==3.8.0 +filelock==3.12.2 # via # -r requirements/test.txt # tox -fixtures==3.0.0 +fixtures==4.1.0 # via # -r requirements/test.txt # cybersource-rest-client-python # testtools freezegun==1.2.2 # via -r requirements/test.txt +frozenlist==1.3.3 + # via + # -r requirements/test.txt + # aiohttp + # aiosignal funcsigs==1.0.2 # via # -r requirements/test.txt # cybersource-rest-client-python -future==0.18.2 +future==0.18.3 # via # -r requirements/test.txt # pyjwkest -getsmarter-api-clients==0.4.0 +getsmarter-api-clients==0.6.0 # via -r requirements/test.txt -gitdb==4.0.9 +gitdb==4.0.10 # via gitpython -gitpython==3.1.27 +gitpython==3.1.31 # via transifex-client -google-api-core==1.30.0 +google-api-core==2.11.1 # via # -r requirements/test.txt # google-api-python-client google-api-python-client==2.31.0 - # via -r requirements/base.in -google-auth-httplib2==0.1.0 # via # -r requirements/test.txt - # google-api-python-client -google-auth==1.32.1 + # inapppy +google-auth==2.20.0 # via # -r requirements/test.txt # google-api-core # google-api-python-client # google-auth-httplib2 -googleapis-common-protos==1.53.0 +google-auth-httplib2==0.1.0 + # via + # -r requirements/test.txt + # google-api-python-client +googleapis-common-protos==1.59.1 # via # -r requirements/test.txt # google-api-core httplib2==0.20.2 - # via -r requirements/base.in -httpretty==0.9.7 - # via -r requirements/test.txt + # via + # -r requirements/test.txt + # google-api-python-client + # google-auth-httplib2 + # oauth2client idna==2.7 # via # -r requirements/docs.txt # -r requirements/test.txt # cybersource-rest-client-python # requests + # yarl imagesize==1.4.1 # via # -r requirements/docs.txt # sphinx -importlib-metadata==4.12.0 +importlib-metadata==6.7.0 # via # -r requirements/docs.txt # -r requirements/test.txt # pytest-randomly # sphinx -importlib-resources==5.9.0 +importlib-resources==5.12.0 # via # -r requirements/test.txt # jsonschema @@ -429,7 +451,7 @@ inflection==0.5.1 # via # -r requirements/test.txt # drf-yasg -iniconfig==1.1.1 +iniconfig==2.0.0 # via # -r requirements/test.txt # pytest @@ -441,7 +463,7 @@ isodate==0.6.1 # via # -r requirements/test.txt # zeep -isort==5.10.1 +isort==5.12.0 # via # -r requirements/test.txt # pylint @@ -463,7 +485,9 @@ jmespath==1.0.1 # botocore jsonfield==3.1.0 # via -r requirements/test.txt -jsonschema==4.16.0 +jsonfield2==4.0.0.post0 + # via -r requirements/test.txt +jsonschema==4.17.3 # via # -r requirements/test.txt # cybersource-rest-client-python @@ -471,11 +495,11 @@ kombu==4.6.11 # via # -r requirements/test.txt # celery -lazy==1.4 +lazy==1.5 # via # -r requirements/test.txt # bok-choy -lazy-object-proxy==1.7.1 +lazy-object-proxy==1.9.0 # via # -r requirements/test.txt # astroid @@ -492,14 +516,14 @@ logger==1.4 # via # -r requirements/test.txt # cybersource-rest-client-python -lxml==4.9.1 +lxml==4.9.2 # via # -r requirements/test.txt # premailer # zeep markdown==2.6.9 # via -r requirements/test.txt -markupsafe==2.1.1 +markupsafe==2.1.3 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -508,31 +532,27 @@ mccabe==0.6.1 # via # -r requirements/test.txt # pylint -mock==4.0.3 +mock==5.0.2 # via -r requirements/test.txt monotonic==1.6 # via # -r requirements/test.txt # analytics-python -more-itertools==8.8.0 - # via - # -r requirements/test.txt - # pytest -multidict==5.1.0 +multidict==6.0.4 # via # -r requirements/test.txt # aiohttp # yarl mysqlclient==1.4.6 # via -r requirements/test.txt -naked==0.1.31 +naked==0.1.32 # via # -r requirements/test.txt # crypto # cybersource-rest-client-python ndg-httpsclient==0.5.1 # via -r requirements/test.txt -newrelic==8.1.0.180 +newrelic==8.8.0 # via # -r requirements/test.txt # edx-django-utils @@ -544,30 +564,26 @@ oauth2client==4.1.3 # via # -r requirements/test.txt # inapppy -oauthlib==3.2.1 +oauthlib==3.2.2 # via # -r requirements/test.txt # getsmarter-api-clients # requests-oauthlib # social-auth-core -openapi-codec==1.3.2 - # via - # -r requirements/test.txt - # django-rest-swagger -packaging==21.3 +packaging==23.1 # via # -r requirements/docs.txt # -r requirements/test.txt # drf-yasg + # pydata-sphinx-theme # pytest - # redis # sphinx # tox -paramiko==2.11.0 +paramiko==3.2.0 # via # -r requirements/test.txt # cybersource-rest-client-python -path==16.4.0 +path==16.6.0 # via # -r requirements/test.txt # edx-i18n-tools @@ -575,18 +591,18 @@ path-py==7.2 # via -r requirements/test.txt paypalrestsdk==1.13.1 # via -r requirements/test.txt -pbr==5.10.0 +pbr==5.11.1 # via # -r requirements/test.txt # cybersource-rest-client-python # fixtures # stevedore # testtools -phonenumbers==8.12.55 +phonenumbers==8.13.14 # via # -r requirements/test.txt # django-oscar -pillow==9.2.0 +pillow==9.5.0 # via # -r requirements/test.txt # django-oscar @@ -594,7 +610,7 @@ pkgutil-resolve-name==1.3.10 # via # -r requirements/test.txt # jsonschema -platformdirs==2.5.2 +platformdirs==3.6.0 # via # -r requirements/test.txt # pylint @@ -605,18 +621,18 @@ pluggy==0.13.1 # diff-cover # pytest # tox -polib==1.1.1 +polib==1.2.0 # via # -r requirements/test.txt # edx-i18n-tools premailer==2.9.2 # via -r requirements/test.txt -protobuf==3.17.3 +protobuf==4.23.3 # via # -r requirements/test.txt # google-api-core # googleapis-common-protos -psutil==5.9.2 +psutil==5.9.5 # via # -r requirements/test.txt # edx-django-utils @@ -629,14 +645,9 @@ purl==1.6 py==1.11.0 # via # -r requirements/test.txt - # pytest + # pytest-html # tox -pyasn1-modules==0.2.8 - # via - # -r requirements/test.txt - # google-auth - # oauth2client -pyasn1==0.4.8 +pyasn1==0.5.0 # via # -r requirements/test.txt # cybersource-rest-client-python @@ -645,45 +656,57 @@ pyasn1==0.4.8 # pyasn1-modules # rsa # x509 -pycodestyle==2.9.1 +pyasn1-modules==0.3.0 + # via + # -r requirements/test.txt + # google-auth + # oauth2client +pycodestyle==2.10.0 # via -r requirements/test.txt pycountry==17.1.8 # via -r requirements/test.txt pycparser==2.21 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python -pycryptodome==3.15.0 +pycryptodome==3.18.0 # via # -r requirements/test.txt # cybersource-rest-client-python -pycryptodomex==3.15.0 +pycryptodomex==3.18.0 # via # -r requirements/test.txt # cybersource-rest-client-python # pyjwkest -pygments==2.13.0 +pydata-sphinx-theme==0.13.3 + # via + # -r requirements/docs.txt + # sphinx-book-theme +pygments==2.15.1 # via # -r requirements/docs.txt # -r requirements/test.txt + # accessible-pygments # diff-cover + # pydata-sphinx-theme # sphinx pyjwkest==1.4.2 + # via -r requirements/test.txt +pyjwt[crypto]==2.7.0 # via # -r requirements/test.txt - # edx-drf-extensions -pyjwt[crypto]==1.7.1 - # via - # -r requirements/test.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends + # edx-drf-extensions # edx-rest-api-client # social-auth-core pylint==2.12.2 # via -r requirements/test.txt -pymongo==3.12.3 +pymongo==3.13.0 # via # -r requirements/test.txt # edx-opaque-keys @@ -693,27 +716,26 @@ pynacl==1.5.0 # cybersource-rest-client-python # edx-django-utils # paramiko -pyopenssl==22.0.0 +pyopenssl==23.2.0 # via # -r requirements/test.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk -pyparsing==3.0.9 +pyparsing==3.1.0 # via - # -r requirements/docs.txt # -r requirements/test.txt # httplib2 - # packaging pypi==2.1 # via # -r requirements/test.txt # cybersource-rest-client-python -pyrsistent==0.18.1 +pyrsistent==0.19.3 # via # -r requirements/test.txt # jsonschema -pytest==6.2.5 +pytest==7.3.2 # via # -r requirements/test.txt # pytest-base-url @@ -725,29 +747,29 @@ pytest==6.2.5 # pytest-selenium # pytest-timeout # pytest-variables -pytest-base-url==1.4.2 +pytest-base-url==2.0.0 # via # -r requirements/test.txt # pytest-selenium -pytest-cov==3.0.0 +pytest-cov==4.1.0 # via -r requirements/test.txt pytest-django==4.5.2 # via -r requirements/test.txt -pytest-html==3.1.1 +pytest-html==3.2.0 # via # -r requirements/test.txt # pytest-selenium -pytest-metadata==2.0.2 +pytest-metadata==3.0.0 # via # -r requirements/test.txt # pytest-html pytest-randomly==3.12.0 # via -r requirements/test.txt -pytest-selenium==3.0.0 +pytest-selenium==2.0.1 # via -r requirements/test.txt pytest-timeout==2.1.0 # via -r requirements/test.txt -pytest-variables==1.9.0 +pytest-variables==2.0.0 # via # -r requirements/test.txt # pytest-selenium @@ -759,7 +781,7 @@ python-dateutil==2.8.2 # edx-drf-extensions # faker # freezegun -python-dotenv==0.21.0 +python-dotenv==1.0.0 # via -r requirements/test.txt python-memcached==1.59 # via -r requirements/test.txt @@ -769,7 +791,7 @@ python-mimeparse==1.6.0 # cybersource-rest-client-python python-slugify==4.0.1 # via transifex-client -python-subunit==1.4.0 +python-subunit==1.4.2 # via # -r requirements/test.txt # cybersource-rest-client-python @@ -781,7 +803,7 @@ python3-openid==3.2.0 # via # -r requirements/test.txt # social-auth-core -pytz==2016.10 +pytz==2023.3 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -792,6 +814,7 @@ pytz==2016.10 # django # djangorestframework # djangorestframework-datatables + # drf-yasg # getsmarter-api-clients # zeep pywatchman==1.4.1 @@ -800,18 +823,20 @@ pyyaml==6.0 # via # -r requirements/test.txt # cybersource-rest-client-python + # drf-yasg # edx-django-release-util # edx-i18n-tools # naked -rcssmin==1.1.0 + # responses +rcssmin==1.1.1 # via # -r requirements/test.txt # django-compressor -redis==4.3.4 +redis==4.5.5 # via # -r requirements/test.txt # edx-ecommerce-worker -requests==2.28.1 +requests==2.31.0 # via # -r requirements/docs.txt # -r requirements/test.txt @@ -846,17 +871,13 @@ requests-oauthlib==1.3.1 # -r requirements/test.txt # getsmarter-api-clients # social-auth-core -requests-toolbelt==0.9.1 +requests-toolbelt==1.0.0 # via # -r requirements/test.txt # zeep -responses==0.21.0 +responses==0.23.1 # via -r requirements/test.txt -rest-condition==1.0.3 - # via - # -r requirements/test.txt - # edx-drf-extensions -rjsmin==1.2.0 +rjsmin==1.2.1 # via # -r requirements/test.txt # django-compressor @@ -864,17 +885,12 @@ rsa==4.9 # via # -r requirements/test.txt # cybersource-rest-client-python -ruamel-yaml==0.17.21 - # via - # -r requirements/test.txt - # drf-yasg -ruamel-yaml-clib==0.2.6 - # via - # -r requirements/test.txt - # ruamel-yaml + # google-auth + # inapppy + # oauth2client rules==3.3 # via -r requirements/test.txt -s3transfer==0.6.0 +s3transfer==0.6.1 # via # -r requirements/test.txt # boto3 @@ -892,15 +908,13 @@ shellescape==3.8.1 # -r requirements/test.txt # crypto # cybersource-rest-client-python -simplejson==3.17.6 +simplejson==3.19.1 # via -r requirements/test.txt six==1.16.0 # via - # -r requirements/docs.txt # -r requirements/test.txt # analytics-python # bleach - # bok-choy # cybersource-rest-client-python # django-extra-views # djangorestframework-csv @@ -909,26 +923,17 @@ six==1.16.0 # edx-drf-extensions # edx-ecommerce-worker # edx-rbac - # edx-sphinx-theme - # fixtures - # google-api-core - # google-api-python-client # google-auth # google-auth-httplib2 - # httpretty # isodate # libsass # oauth2client - # paramiko # paypalrestsdk - # protobuf # purl # pyjwkest # python-dateutil # python-memcached # requests-file - # social-auth-app-django - # social-auth-core # tenacity # tox # transifex-client @@ -942,26 +947,30 @@ snowballstemmer==2.2.0 # via # -r requirements/docs.txt # sphinx -social-auth-app-django==4.0.0 +social-auth-app-django==5.2.0 # via # -r requirements/test.txt # edx-auth-backends -social-auth-core==4.0.2 +social-auth-core==4.4.2 # via # -r requirements/test.txt # edx-auth-backends # social-auth-app-django sorl-thumbnail==12.9.0 # via -r requirements/test.txt -soupsieve==2.3.2.post1 +soupsieve==2.4.1 # via + # -r requirements/docs.txt # -r requirements/test.txt # beautifulsoup4 -sphinx==5.1.1 +sphinx==5.3.0 # via # -r requirements/docs.txt - # edx-sphinx-theme -sphinxcontrib-applehelp==1.0.2 + # pydata-sphinx-theme + # sphinx-book-theme +sphinx-book-theme==1.0.1 + # via -r requirements/docs.txt +sphinxcontrib-applehelp==1.0.4 # via # -r requirements/docs.txt # sphinx @@ -969,7 +978,7 @@ sphinxcontrib-devhelp==1.0.2 # via # -r requirements/docs.txt # sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.1 # via # -r requirements/docs.txt # sphinx @@ -985,25 +994,25 @@ sphinxcontrib-serializinghtml==1.1.5 # via # -r requirements/docs.txt # sphinx -sqlparse==0.4.2 +sqlparse==0.4.4 # via # -r requirements/test.txt # django # django-debug-toolbar -stevedore==4.0.0 +stevedore==5.1.0 # via # -r requirements/test.txt # edx-django-utils # edx-opaque-keys -stripe==4.1.0 +stripe==5.4.0 # via -r requirements/test.txt tenacity==6.3.1 # via # -r requirements/test.txt # pytest-selenium -testfixtures==7.0.0 +testfixtures==7.1.0 # via -r requirements/test.txt -testtools==2.5.0 +testtools==2.6.0 # via # -r requirements/test.txt # cybersource-rest-client-python @@ -1014,12 +1023,12 @@ toml==0.10.2 # via # -r requirements/test.txt # pylint - # pytest # tox tomli==2.0.1 # via # -r requirements/test.txt # coverage + # pytest tox==3.14.6 # via # -r requirements/test.txt @@ -1032,14 +1041,21 @@ traceback2==1.4.0 # cybersource-rest-client-python transifex-client==0.14.4 # via -r requirements/dev.in +types-pyyaml==6.0.12.10 + # via + # -r requirements/test.txt + # responses typing==3.7.4.3 # via # -r requirements/test.txt # cybersource-rest-client-python -typing-extensions==4.3.0 +typing-extensions==4.6.3 # via + # -r requirements/docs.txt # -r requirements/test.txt + # asgiref # astroid + # pydata-sphinx-theme # pylint unicodecsv==0.14.1 # via @@ -1050,12 +1066,14 @@ uritemplate==4.1.1 # -r requirements/test.txt # coreapi # drf-yasg -urllib3==1.26.12 + # google-api-python-client +urllib3==1.26.16 # via # -r requirements/docs.txt # -r requirements/test.txt # botocore # cybersource-rest-client-python + # google-auth # requests # responses # selenium @@ -1085,7 +1103,7 @@ webtest==3.0.0 # via # -r requirements/test.txt # django-webtest -wheel==0.37.1 +wheel==0.40.0 # via # -r requirements/test.txt # cybersource-rest-client-python @@ -1093,26 +1111,25 @@ wrapt==1.13.3 # via # -r requirements/test.txt # astroid - # deprecated x509==0.1 # via # -r requirements/test.txt # cybersource-rest-client-python xss-utils==0.4.0 # via -r requirements/test.txt -yarl==1.6.3 +yarl==1.9.2 # via # -r requirements/test.txt # aiohttp -zeep==4.1.0 +zeep==4.2.1 # via -r requirements/test.txt -zipp==3.8.1 +zipp==3.15.0 # via # -r requirements/docs.txt # -r requirements/test.txt # importlib-metadata # importlib-resources -zope-interface==5.4.0 +zope-interface==6.0 # via # -r requirements/test.txt # cybersource-rest-client-python diff --git a/requirements/docs.in b/requirements/docs.in index b248a1201b1..ad49c629244 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,4 +1,4 @@ -c constraints.txt -edx-sphinx-theme +sphinx-book-theme Sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index 4d8ba7517ac..1f2dae3e304 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,58 +1,71 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -alabaster==0.7.12 +accessible-pygments==0.0.4 + # via pydata-sphinx-theme +alabaster==0.7.13 # via sphinx -babel==2.10.3 - # via sphinx -certifi==2022.9.14 +babel==2.12.1 + # via + # pydata-sphinx-theme + # sphinx +beautifulsoup4==4.12.2 + # via pydata-sphinx-theme +certifi==2023.5.7 # via requests -charset-normalizer==2.1.1 +charset-normalizer==3.1.0 # via requests docutils==0.19 - # via sphinx -edx-sphinx-theme==3.0.0 - # via -r requirements/docs.in + # via + # pydata-sphinx-theme + # sphinx idna==2.7 # via # -c requirements/constraints.txt # requests imagesize==1.4.1 # via sphinx -importlib-metadata==4.12.0 +importlib-metadata==6.7.0 # via sphinx jinja2==3.1.2 # via sphinx -markupsafe==2.1.1 +markupsafe==2.1.3 # via jinja2 -packaging==21.3 - # via sphinx -pygments==2.13.0 - # via sphinx -pyparsing==3.0.9 - # via packaging -pytz==2016.10 +packaging==23.1 # via - # -c requirements/constraints.txt - # babel -requests==2.28.1 + # pydata-sphinx-theme + # sphinx +pydata-sphinx-theme==0.13.3 + # via sphinx-book-theme +pygments==2.15.1 + # via + # accessible-pygments + # pydata-sphinx-theme + # sphinx +pytz==2023.3 + # via babel +requests==2.31.0 # via sphinx -six==1.16.0 - # via edx-sphinx-theme snowballstemmer==2.2.0 # via sphinx -sphinx==5.1.1 +soupsieve==2.4.1 + # via beautifulsoup4 +sphinx==5.3.0 # via + # -c requirements/common_constraints.txt # -r requirements/docs.in - # edx-sphinx-theme -sphinxcontrib-applehelp==1.0.2 + # pydata-sphinx-theme + # sphinx-book-theme +sphinx-book-theme==1.0.1 + # via -r requirements/docs.in +sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx -sphinxcontrib-htmlhelp==2.0.0 +sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx @@ -60,9 +73,11 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -urllib3==1.26.12 +typing-extensions==4.6.3 + # via pydata-sphinx-theme +urllib3==1.26.16 # via # -c requirements/constraints.txt # requests -zipp==3.8.1 +zipp==3.15.0 # via importlib-metadata diff --git a/requirements/e2e.in b/requirements/e2e.in index b76e03170c8..116af7d0bd2 100644 --- a/requirements/e2e.in +++ b/requirements/e2e.in @@ -3,6 +3,7 @@ # Packages required to run e2e tests edx-rest-api-client +pyjwkest pytest pytest-randomly pytest-selenium diff --git a/requirements/e2e.txt b/requirements/e2e.txt index 8f3a5412109..a16e052bd76 100644 --- a/requirements/e2e.txt +++ b/requirements/e2e.txt @@ -1,18 +1,14 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -asgiref==3.5.2 +asgiref==3.7.2 # via # -c requirements/base.txt # django -attrs==22.1.0 - # via - # -c requirements/base.txt - # pytest -certifi==2022.9.14 +certifi==2023.5.7 # via # -c requirements/base.txt # requests @@ -21,7 +17,7 @@ cffi==1.15.1 # -c requirements/base.txt # cryptography # pynacl -charset-normalizer==2.1.1 +charset-normalizer==3.1.0 # via # -c requirements/base.txt # requests @@ -29,11 +25,11 @@ click==8.1.3 # via # -c requirements/base.txt # edx-django-utils -cryptography==38.0.1 +cryptography==41.0.1 # via # -c requirements/base.txt # pyjwt -django==3.2.15 +django==3.2.20 # via # -c requirements/base.txt # -c requirements/common_constraints.txt @@ -47,32 +43,36 @@ django-waffle==3.0.0 # via # -c requirements/base.txt # edx-django-utils -edx-django-utils==5.0.1 +edx-django-utils==5.5.0 # via # -c requirements/base.txt # edx-rest-api-client -edx-rest-api-client==5.5.0 +edx-rest-api-client==5.5.2 # via # -c requirements/base.txt # -r requirements/e2e.in +exceptiongroup==1.1.1 + # via pytest +future==0.18.3 + # via pyjwkest idna==2.7 # via # -c requirements/base.txt # -c requirements/constraints.txt # requests -importlib-metadata==4.12.0 +importlib-metadata==6.7.0 # via pytest-randomly -iniconfig==1.1.1 +iniconfig==2.0.0 # via pytest -newrelic==8.1.0.180 +newrelic==8.8.0 # via # -c requirements/base.txt # edx-django-utils -packaging==21.3 +packaging==23.1 # via # -c requirements/base.txt # pytest -pbr==5.10.0 +pbr==5.11.1 # via # -c requirements/base.txt # stevedore @@ -80,17 +80,23 @@ pluggy==0.13.1 # via # -c requirements/constraints.txt # pytest -psutil==5.9.2 +psutil==5.9.5 # via # -c requirements/base.txt # edx-django-utils py==1.11.0 - # via pytest + # via pytest-html pycparser==2.21 # via # -c requirements/base.txt # cffi -pyjwt[crypto]==1.7.1 +pycryptodomex==3.18.0 + # via + # -c requirements/base.txt + # pyjwkest +pyjwkest==1.4.2 + # via -r requirements/e2e.in +pyjwt[crypto]==2.7.0 # via # -c requirements/base.txt # edx-rest-api-client @@ -98,11 +104,7 @@ pynacl==1.5.0 # via # -c requirements/base.txt # edx-django-utils -pyparsing==3.0.9 - # via - # -c requirements/base.txt - # packaging -pytest==6.2.5 +pytest==7.3.2 # via # -r requirements/e2e.in # pytest-base-url @@ -112,31 +114,35 @@ pytest==6.2.5 # pytest-selenium # pytest-timeout # pytest-variables -pytest-base-url==1.4.2 +pytest-base-url==2.0.0 # via pytest-selenium -pytest-html==3.1.1 +pytest-html==3.2.0 # via pytest-selenium -pytest-metadata==2.0.2 +pytest-metadata==3.0.0 # via pytest-html pytest-randomly==3.12.0 # via -r requirements/e2e.in -pytest-selenium==3.0.0 - # via -r requirements/e2e.in +pytest-selenium==2.0.1 + # via + # -c requirements/constraints.txt + # -r requirements/e2e.in pytest-timeout==2.1.0 # via -r requirements/e2e.in -pytest-variables==1.9.0 - # via pytest-selenium -python-dotenv==0.21.0 +pytest-variables==2.0.0 + # via + # -c requirements/constraints.txt + # pytest-selenium +python-dotenv==1.0.0 # via -r requirements/e2e.in -pytz==2016.10 +pytz==2023.3 # via # -c requirements/base.txt - # -c requirements/constraints.txt # django -requests==2.28.1 +requests==2.31.0 # via # -c requirements/base.txt # edx-rest-api-client + # pyjwkest # pytest-base-url # pytest-selenium # slumber @@ -148,32 +154,35 @@ selenium==3.141.0 six==1.16.0 # via # -c requirements/base.txt + # pyjwkest # tenacity slumber==0.7.1 # via # -c requirements/base.txt # edx-rest-api-client -sqlparse==0.4.2 +sqlparse==0.4.4 # via # -c requirements/base.txt # django -stevedore==4.0.0 +stevedore==5.1.0 # via # -c requirements/base.txt # edx-django-utils tenacity==6.3.1 # via pytest-selenium -toml==0.10.2 +tomli==2.0.1 # via pytest -urllib3==1.26.12 +typing-extensions==4.6.3 + # via + # -c requirements/base.txt + # asgiref +urllib3==1.26.16 # via # -c requirements/base.txt # -c requirements/constraints.txt # requests # selenium -wcwidth==0.2.5 - # via pytest -zipp==3.8.1 +zipp==3.15.0 # via # -c requirements/base.txt # importlib-metadata diff --git a/requirements/pip.txt b/requirements/pip.txt index 35b87891c26..fa19e6f0b1a 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -1,16 +1,14 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -wheel==0.37.1 +wheel==0.40.0 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: -pip==22.2.2 +pip==23.1.2 + # via -r requirements/pip.in +setuptools==68.0.0 # via -r requirements/pip.in -setuptools==59.8.0 - # via - # -c requirements/common_constraints.txt - # -r requirements/pip.in diff --git a/requirements/pip_tools.txt b/requirements/pip_tools.txt index 6512312bb2c..b6150065453 100644 --- a/requirements/pip_tools.txt +++ b/requirements/pip_tools.txt @@ -1,26 +1,22 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -build==0.8.0 +build==0.10.0 # via pip-tools click==8.1.3 # via pip-tools -packaging==21.3 +packaging==23.1 # via build -pep517==0.13.0 - # via build -pip-tools==6.8.0 +pip-tools==6.13.0 # via -r requirements/pip_tools.in -pyparsing==3.0.9 - # via packaging +pyproject-hooks==1.0.0 + # via build tomli==2.0.1 - # via - # build - # pep517 -wheel==0.37.1 + # via build +wheel==0.40.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/production.txt b/requirements/production.txt index 15fbe607eb6..8906a420a65 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -1,116 +1,119 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -aiohttp==3.8.0 +aiohttp==3.8.4 # via inapppy +aiosignal==1.3.1 + # via aiohttp amqp==2.6.1 # via kombu -analytics-python==1.4.0 +analytics-python==1.4.post1 + # via -r requirements/base.in +app-store-notifications-v2-validator==0.0.7 # via -r requirements/base.in -asgiref==3.5.2 +asgiref==3.7.2 # via django asn1crypto==1.5.1 # via cybersource-rest-client-python async-timeout==4.0.2 - # via redis -attrs==22.1.0 + # via + # aiohttp + # redis +attrs==23.1.0 # via # aiohttp # jsonschema # zeep -babel==2.10.3 +babel==2.12.1 # via django-oscar backoff==1.10.0 # via analytics-python -bcrypt==4.0.0 +bcrypt==4.0.1 # via # cybersource-rest-client-python # paramiko billiard==3.6.4.0 # via celery -bleach==5.0.1 +bleach==6.0.0 # via -r requirements/base.in -boto3==1.24.73 +boto3==1.26.155 # via # -r requirements/base.in # django-ses -botocore==1.27.73 +botocore==1.29.155 # via # boto3 # s3transfer -cached-property==1.5.2 - # via zeep -cachetools==4.2.2 +cachetools==5.3.1 # via google-auth celery==4.4.7 # via # -c requirements/constraints.txt # edx-ecommerce-worker -certifi==2022.9.14 +certifi==2023.5.7 # via # cybersource-rest-client-python # requests cffi==1.15.1 # via + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl -chardet==5.0.0 +chardet==5.1.0 + # via cybersource-rest-client-python +charset-normalizer==3.1.0 # via # aiohttp - # cybersource-rest-client-python # requests -charset-normalizer==2.1.1 - # via requests click==8.1.3 # via edx-django-utils configparser==5.3.0 # via cybersource-rest-client-python coreapi==2.3.3 - # via - # -r requirements/base.in - # drf-yasg + # via -r requirements/base.in coreschema==0.0.4 - # via - # coreapi - # drf-yasg -coverage==6.4.4 + # via coreapi +coverage==7.2.7 # via cybersource-rest-client-python +crispy-bootstrap3==2022.1 + # via -r requirements/base.in crypto==1.4.1 # via cybersource-rest-client-python -cryptography==38.0.1 +cryptography==41.0.1 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt # pyopenssl # social-auth-core -cssselect==1.1.0 +cssselect==1.2.0 # via premailer -cssutils==2.6.0 +cssutils==2.7.1 # via premailer cybersource-rest-client-python==0.0.21 # via # -c requirements/constraints.txt # -r requirements/base.in -datetime==4.7 +datetime==5.1 # via cybersource-rest-client-python defusedxml==0.7.1 # via # python3-openid # social-auth-core -deprecated==1.2.13 - # via redis -django==3.2.15 +django==3.2.20 # via # -c requirements/common_constraints.txt # -r requirements/base.in + # crispy-bootstrap3 # django-appconf # django-config-models # django-cors-headers + # django-crispy-forms # django-crum # django-extensions # django-extra-views @@ -133,35 +136,38 @@ django==3.2.15 # edx-drf-extensions # edx-rbac # jsonfield - # rest-condition + # jsonfield2 + # social-auth-app-django # xss-utils django-appconf==1.0.5 # via django-compressor -django-compressor==4.1 +django-compressor==4.3.1 # via # -r requirements/base.in # django-libsass django-config-models==2.3.0 # via -r requirements/base.in -django-cors-headers==3.13.0 - # via -r requirements/base.in -django-crispy-forms==1.14.0 +django-cors-headers==4.1.0 # via -r requirements/base.in +django-crispy-forms==2.0 + # via + # -r requirements/base.in + # crispy-bootstrap3 django-crum==0.7.9 # via # edx-django-utils # edx-rbac -django-extensions==3.2.1 +django-extensions==3.2.3 # via -r requirements/base.in django-extra-views==0.13.0 # via django-oscar -django-filter==22.1 +django-filter==23.2 # via -r requirements/base.in -django-haystack==3.1.1 +django-haystack==3.2.1 # via django-oscar django-libsass==0.9 # via -r requirements/base.in -django-model-utils==4.2.0 +django-model-utils==4.3.1 # via edx-rbac django-oscar==2.2 # via @@ -169,13 +175,13 @@ django-oscar==2.2 # -r requirements/base.in django-phonenumber-field==5.0.0 # via django-oscar -django-ses==3.1.2 +django-ses==3.5.0 # via -r requirements/production.in django-simple-history==3.0.0 # via # -c requirements/common_constraints.txt # -r requirements/base.in -django-solo==2.0.0 +django-solo==2.1.0 # via -r requirements/base.in django-tables2==2.4.1 # via django-oscar @@ -190,7 +196,7 @@ django-waffle==3.0.0 # edx-drf-extensions django-widget-tweaks==1.4.12 # via django-oscar -djangorestframework==3.13.1 +djangorestframework==3.14.0 # via # -r requirements/base.in # django-config-models @@ -200,7 +206,6 @@ djangorestframework==3.13.1 # drf-jwt # drf-yasg # edx-drf-extensions - # rest-condition djangorestframework-csv==2.1.1 # via -r requirements/base.in djangorestframework-datatables==0.7.0 @@ -209,33 +214,28 @@ drf-extensions==0.7.1 # via -r requirements/base.in drf-jwt==1.19.2 # via edx-drf-extensions -drf-yasg==1.20.0 - # via - # -c requirements/constraints.txt - # -r requirements/base.in -edx-auth-backends==3.4.0 - # via - # -c requirements/constraints.txt - # -r requirements/base.in -edx-braze-client==0.1.4 +drf-yasg==1.21.6 + # via -r requirements/base.in +edx-auth-backends==4.1.0 + # via -r requirements/base.in +edx-braze-client==0.1.6 # via edx-ecommerce-worker edx-django-release-util==1.2.0 # via -r requirements/base.in edx-django-sites-extensions==4.0.0 # via -r requirements/base.in -edx-django-utils==5.0.1 +edx-django-utils==5.5.0 # via # -r requirements/base.in # django-config-models # edx-drf-extensions # edx-rest-api-client # getsmarter-api-clients -edx-drf-extensions==6.6.0 +edx-drf-extensions==8.8.0 # via - # -c requirements/constraints.txt # -r requirements/base.in # edx-rbac -edx-ecommerce-worker==3.3.2 +edx-ecommerce-worker==3.3.4 # via -r requirements/base.in edx-opaque-keys==2.3.0 # via @@ -243,7 +243,7 @@ edx-opaque-keys==2.3.0 # edx-drf-extensions edx-rbac==1.7.0 # via -r requirements/base.in -edx-rest-api-client==5.5.0 +edx-rest-api-client==5.5.2 # via # -r requirements/base.in # edx-ecommerce-worker @@ -253,49 +253,52 @@ extras==1.0.0 # via # cybersource-rest-client-python # python-subunit - # testtools factory-boy==2.12.0 # via django-oscar -faker==14.2.0 +faker==18.10.1 # via factory-boy -fixtures==3.0.0 +fixtures==4.1.0 # via # cybersource-rest-client-python # testtools +frozenlist==1.3.3 + # via + # aiohttp + # aiosignal funcsigs==1.0.2 # via cybersource-rest-client-python -future==0.18.2 - # via - # django-ses - # pyjwkest -getsmarter-api-clients==0.4.0 +getsmarter-api-clients==0.6.0 # via -r requirements/base.in - # via - # django-ses - # pyjwkest -google-api-core==1.30.0 +google-api-core==2.11.1 # via google-api-python-client google-api-python-client==2.31.0 - # via -r requirements/base.in -google-auth-httplib2==0.1.0 - # via google-api-python-client -google-auth==1.32.1 + # via + # -r requirements/base.in + # inapppy +google-auth==2.20.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 -googleapis-common-protos==1.53.0 +google-auth-httplib2==0.1.0 + # via google-api-python-client +googleapis-common-protos==1.59.1 # via google-api-core gunicorn==19.7.1 # via -r requirements/production.in httplib2==0.20.2 - # via -r requirements/base.in + # via + # -r requirements/base.in + # google-api-python-client + # google-auth-httplib2 + # oauth2client idna==2.7 # via # -c requirements/constraints.txt # cybersource-rest-client-python # requests -importlib-resources==5.9.0 + # yarl +importlib-resources==5.12.0 # via jsonschema inapppy==2.5.2 # via -r requirements/base.in @@ -315,7 +318,9 @@ jmespath==1.0.1 # botocore jsonfield==3.1.0 # via -r requirements/base.in -jsonschema==4.16.0 +jsonfield2==4.0.0.post0 + # via -r requirements/base.in +jsonschema==4.17.3 # via cybersource-rest-client-python kombu==4.6.11 # via celery @@ -329,23 +334,23 @@ linecache2==1.0.0 # traceback2 logger==1.4 # via cybersource-rest-client-python -lxml==4.9.1 +lxml==4.9.2 # via # premailer # zeep markdown==2.6.9 # via -r requirements/base.in -markupsafe==2.1.1 +markupsafe==2.1.3 # via jinja2 monotonic==1.6 # via analytics-python -multidict==5.1.0 +multidict==6.0.4 # via # aiohttp # yarl mysqlclient==1.4.6 # via -r requirements/base.in -naked==0.1.31 +naked==0.1.32 # via # crypto # cybersource-rest-client-python @@ -362,52 +367,44 @@ nose==1.3.7 # via cybersource-rest-client-python oauth2client==4.1.3 # via inapppy -oauthlib==3.2.1 +oauthlib==3.2.2 # via # getsmarter-api-clients # requests-oauthlib # social-auth-core -openapi-codec==1.3.2 - # via django-rest-swagger -packaging==21.3 - # via - # drf-yasg - # redis -paramiko==2.11.0 +packaging==23.1 + # via drf-yasg +paramiko==3.2.0 # via cybersource-rest-client-python path-py==7.2 # via -r requirements/base.in paypalrestsdk==1.13.1 # via -r requirements/base.in -pbr==5.10.0 +pbr==5.11.1 # via # cybersource-rest-client-python # fixtures # stevedore # testtools -phonenumbers==8.12.55 +phonenumbers==8.13.14 # via django-oscar -pillow==9.2.0 +pillow==9.5.0 # via django-oscar pkgutil-resolve-name==1.3.10 # via jsonschema -platformdirs==2.5.2 +platformdirs==3.6.0 # via zeep premailer==2.9.2 # via -r requirements/base.in -protobuf==3.17.3 +protobuf==4.23.3 # via # google-api-core # googleapis-common-protos -psutil==5.9.2 +psutil==5.9.5 # via edx-django-utils purl==1.6 # via django-oscar -pyasn1-modules==0.2.8 - # via - # google-auth - # oauth2client -pyasn1==0.4.8 +pyasn1==0.5.0 # via # cybersource-rest-client-python # ndg-httpsclient @@ -415,46 +412,50 @@ pyasn1==0.4.8 # pyasn1-modules # rsa # x509 +pyasn1-modules==0.3.0 + # via + # google-auth + # oauth2client pycountry==17.1.8 # via -r requirements/base.in pycparser==2.21 # via + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python -pycryptodome==3.15.0 +pycryptodome==3.18.0 # via cybersource-rest-client-python -pycryptodomex==3.15.0 - # via - # cybersource-rest-client-python - # pyjwkest -pygments==2.13.0 +pycryptodomex==3.18.0 + # via cybersource-rest-client-python +pygments==2.15.1 # via -r requirements/base.in -pyjwkest==1.4.2 - # via edx-drf-extensions -pyjwt[crypto]==1.7.1 +pyjwt[crypto]==2.7.0 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends + # edx-drf-extensions # edx-rest-api-client # social-auth-core -pymongo==3.12.3 +pymongo==3.13.0 # via edx-opaque-keys pynacl==1.5.0 # via # cybersource-rest-client-python # edx-django-utils # paramiko -pyopenssl==22.0.0 +pyopenssl==23.2.0 # via + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk -pyparsing==3.0.9 - # via packaging +pyparsing==3.1.0 + # via httplib2 pypi==2.1 # via cybersource-rest-client-python -pyrsistent==0.18.1 +pyrsistent==0.19.3 # via jsonschema python-dateutil==2.8.2 # via @@ -467,7 +468,7 @@ python-memcached==1.59 # via -r requirements/production.in python-mimeparse==1.6.0 # via cybersource-rest-client-python -python-subunit==1.4.0 +python-subunit==1.4.2 # via cybersource-rest-client-python python-toolbox==1.0.11 # via cybersource-rest-client-python @@ -475,9 +476,8 @@ python3-openid==3.2.0 # via # -r requirements/base.in # social-auth-core -pytz==2016.10 +pytz==2023.3 # via - # -c requirements/constraints.txt # -r requirements/base.in # babel # celery @@ -487,21 +487,23 @@ pytz==2016.10 # django-ses # djangorestframework # djangorestframework-datatables + # drf-yasg # getsmarter-api-clients # zeep pyyaml==6.0 # via # -r requirements/production.in # cybersource-rest-client-python + # drf-yasg # edx-django-release-util # naked -rcssmin==1.1.0 +rcssmin==1.1.1 # via django-compressor -redis==4.3.4 +redis==4.5.5 # via # -r requirements/production.in # edx-ecommerce-worker -requests==2.28.1 +requests==2.31.0 # via # -r requirements/base.in # analytics-python @@ -513,7 +515,6 @@ requests==2.28.1 # inapppy # naked # paypalrestsdk - # pyjwkest # requests-file # requests-oauthlib # requests-toolbelt @@ -527,21 +528,19 @@ requests-oauthlib==1.3.1 # via # getsmarter-api-clients # social-auth-core -requests-toolbelt==0.9.1 +requests-toolbelt==1.0.0 # via zeep -rest-condition==1.0.3 - # via edx-drf-extensions -rjsmin==1.2.0 +rjsmin==1.2.1 # via django-compressor rsa==4.9 - # via cybersource-rest-client-python -ruamel-yaml==0.17.21 - # via drf-yasg -ruamel-yaml-clib==0.2.6 - # via ruamel-yaml + # via + # cybersource-rest-client-python + # google-auth + # inapppy + # oauth2client rules==3.3 # via -r requirements/base.in -s3transfer==0.6.0 +s3transfer==0.6.1 # via boto3 semantic-version==2.10.0 # via edx-drf-extensions @@ -549,7 +548,7 @@ shellescape==3.8.1 # via # crypto # cybersource-rest-client-python -simplejson==3.17.6 +simplejson==3.19.1 # via -r requirements/base.in six==1.16.0 # via @@ -563,70 +562,61 @@ six==1.16.0 # edx-drf-extensions # edx-ecommerce-worker # edx-rbac - # google-api-core - # google-api-python-client # google-auth # google-auth-httplib2 # isodate # libsass # oauth2client - # paramiko # paypalrestsdk - # protobuf # purl - # pyjwkest # python-dateutil # python-memcached # requests-file - # social-auth-app-django - # social-auth-core slumber==0.7.1 # via edx-rest-api-client -social-auth-app-django==4.0.0 +social-auth-app-django==5.2.0 # via - # -c requirements/constraints.txt # -r requirements/base.in # edx-auth-backends -social-auth-core==4.0.2 +social-auth-core==4.4.2 # via - # -c requirements/constraints.txt # edx-auth-backends # social-auth-app-django sorl-thumbnail==12.9.0 # via -r requirements/base.in -sqlparse==0.4.2 +sqlparse==0.4.4 # via django -stevedore==4.0.0 +stevedore==5.1.0 # via # edx-django-utils # edx-opaque-keys -stripe==4.1.0 +stripe==5.4.0 # via -r requirements/base.in -testtools==2.5.0 +testtools==2.6.0 # via # cybersource-rest-client-python # python-subunit traceback2==1.4.0 # via cybersource-rest-client-python -typing-extensions==4.0.1 - # via aiohttp typing==3.7.4.3 # via cybersource-rest-client-python +typing-extensions==4.6.3 + # via asgiref unicodecsv==0.14.1 # via # -r requirements/base.in # djangorestframework-csv -unittest2==1.1.0 - # via testtools uritemplate==4.1.1 # via # coreapi # drf-yasg -urllib3==1.26.12 + # google-api-python-client +urllib3==1.26.16 # via # -c requirements/constraints.txt # botocore # cybersource-rest-client-python + # google-auth # requests vine==1.3.0 # via @@ -634,23 +624,19 @@ vine==1.3.0 # celery webencodings==0.5.1 # via bleach -wheel==0.37.1 +wheel==0.40.0 # via cybersource-rest-client-python -wrapt==1.13.3 - # via - # -c requirements/constraints.txt - # deprecated x509==0.1 # via cybersource-rest-client-python xss-utils==0.4.0 # via -r requirements/base.in -yarl==1.6.3 +yarl==1.9.2 # via aiohttp -zeep==4.1.0 +zeep==4.2.1 # via -r requirements/base.in -zipp==3.8.1 +zipp==3.15.0 # via importlib-resources -zope-interface==5.4.0 +zope-interface==6.0 # via # cybersource-rest-client-python # datetime diff --git a/requirements/test.txt b/requirements/test.txt index e2591d258da..a4c81da0534 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,20 +1,26 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -aiohttp==3.8.0 +aiohttp==3.8.4 # via # -r requirements/base.txt # inapppy +aiosignal==1.3.1 + # via + # -r requirements/base.txt + # aiohttp amqp==2.6.1 # via # -r requirements/base.txt # kombu -analytics-python==1.4.0 +analytics-python==1.4.post1 + # via -r requirements/base.txt +app-store-notifications-v2-validator==0.0.7 # via -r requirements/base.txt -asgiref==3.5.2 +asgiref==3.7.2 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -28,16 +34,15 @@ astroid==2.9.3 async-timeout==4.0.2 # via # -r requirements/base.txt + # aiohttp # redis -attrs==22.1.0 +attrs==23.1.0 # via # -r requirements/base.txt - # -r requirements/e2e.txt # aiohttp # jsonschema - # pytest # zeep -babel==2.10.3 +babel==2.12.1 # via # -r requirements/base.txt # django-oscar @@ -45,33 +50,29 @@ backoff==1.10.0 # via # -r requirements/base.txt # analytics-python -bcrypt==4.0.0 +bcrypt==4.0.1 # via # -r requirements/base.txt # cybersource-rest-client-python # paramiko -beautifulsoup4==4.11.1 +beautifulsoup4==4.12.2 # via webtest billiard==3.6.4.0 # via # -r requirements/base.txt # celery -bleach==5.0.1 +bleach==6.0.0 # via -r requirements/base.txt -bok-choy==1.1.1 +bok-choy==2.0.2 # via -r requirements/test.in -boto3==1.24.73 +boto3==1.26.155 # via -r requirements/base.txt -botocore==1.27.73 +botocore==1.29.155 # via # -r requirements/base.txt # boto3 # s3transfer -cached-property==1.5.2 - # via - # -r requirements/base.txt - # zeep -cachetools==4.2.2 +cachetools==5.3.1 # via # -r requirements/base.txt # google-auth @@ -80,7 +81,7 @@ celery==4.4.7 # -c requirements/constraints.txt # -r requirements/base.txt # edx-ecommerce-worker -certifi==2022.9.14 +certifi==2023.5.7 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -90,20 +91,20 @@ cffi==1.15.1 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cryptography # cybersource-rest-client-python # pynacl -chardet==5.0.0 +chardet==5.1.0 # via # -r requirements/base.txt - # -r requirements/e2e.txt - # aiohttp # cybersource-rest-client-python # diff-cover -charset-normalizer==2.1.1 +charset-normalizer==3.1.0 # via # -r requirements/base.txt # -r requirements/e2e.txt + # aiohttp # requests click==8.1.3 # via @@ -115,38 +116,38 @@ configparser==5.3.0 # -r requirements/base.txt # cybersource-rest-client-python coreapi==2.3.3 - # via - # -r requirements/base.txt - # drf-yasg + # via -r requirements/base.txt coreschema==0.0.4 # via # -r requirements/base.txt # coreapi - # drf-yasg -coverage[toml]==6.4.4 +coverage[toml]==7.2.7 # via # -r requirements/base.txt # -r requirements/test.in # cybersource-rest-client-python # pytest-cov +crispy-bootstrap3==2022.1 + # via -r requirements/base.txt crypto==1.4.1 # via # -r requirements/base.txt # cybersource-rest-client-python -cryptography==38.0.1 +cryptography==41.0.1 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # paramiko # pyjwt # pyopenssl # social-auth-core -cssselect==1.1.0 +cssselect==1.2.0 # via # -r requirements/base.txt # premailer -cssutils==2.6.0 +cssutils==2.7.1 # via # -r requirements/base.txt # premailer @@ -154,7 +155,7 @@ cybersource-rest-client-python==0.0.21 # via # -c requirements/constraints.txt # -r requirements/base.txt -datetime==4.7 +datetime==5.1 # via # -r requirements/base.txt # cybersource-rest-client-python @@ -165,19 +166,17 @@ defusedxml==0.7.1 # -r requirements/base.txt # python3-openid # social-auth-core -deprecated==1.2.13 - # via - # -r requirements/base.txt - # redis -diff-cover==6.5.1 +diff-cover==7.6.0 # via -r requirements/test.in # via # -c requirements/common_constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt + # crispy-bootstrap3 # django-appconf # django-config-models # django-cors-headers + # django-crispy-forms # django-crum # django-extensions # django-extra-views @@ -200,43 +199,46 @@ diff-cover==6.5.1 # edx-i18n-tools # edx-rbac # jsonfield - # rest-condition + # jsonfield2 + # social-auth-app-django # xss-utils django-appconf==1.0.5 # via # -r requirements/base.txt # django-compressor -django-compressor==4.1 +django-compressor==4.3.1 # via # -r requirements/base.txt # django-libsass django-config-models==2.3.0 # via -r requirements/base.txt -django-cors-headers==3.13.0 - # via -r requirements/base.txt -django-crispy-forms==1.14.0 +django-cors-headers==4.1.0 # via -r requirements/base.txt +django-crispy-forms==2.0 + # via + # -r requirements/base.txt + # crispy-bootstrap3 django-crum==0.7.9 # via # -r requirements/base.txt # -r requirements/e2e.txt # edx-django-utils # edx-rbac -django-extensions==3.2.1 +django-extensions==3.2.3 # via -r requirements/base.txt django-extra-views==0.13.0 # via # -r requirements/base.txt # django-oscar -django-filter==22.1 +django-filter==23.2 # via -r requirements/base.txt -django-haystack==3.1.1 +django-haystack==3.2.1 # via # -r requirements/base.txt # django-oscar django-libsass==0.9 # via -r requirements/base.txt -django-model-utils==4.2.0 +django-model-utils==4.3.1 # via # -r requirements/base.txt # edx-rbac @@ -252,7 +254,7 @@ django-simple-history==3.0.0 # via # -c requirements/common_constraints.txt # -r requirements/base.txt -django-solo==2.0.0 +django-solo==2.1.0 # via -r requirements/base.txt django-tables2==2.4.1 # via @@ -276,7 +278,7 @@ django-widget-tweaks==1.4.12 # via # -r requirements/base.txt # django-oscar -djangorestframework==3.13.1 +djangorestframework==3.14.0 # via # -r requirements/base.txt # django-config-models @@ -286,7 +288,6 @@ djangorestframework==3.13.1 # drf-jwt # drf-yasg # edx-drf-extensions - # rest-condition djangorestframework-csv==2.1.1 # via -r requirements/base.txt djangorestframework-datatables==0.7.0 @@ -297,15 +298,11 @@ drf-jwt==1.19.2 # via # -r requirements/base.txt # edx-drf-extensions -drf-yasg==1.20.0 - # via - # -c requirements/constraints.txt - # -r requirements/base.txt -edx-auth-backends==3.4.0 - # via - # -c requirements/constraints.txt - # -r requirements/base.txt -edx-braze-client==0.1.4 +drf-yasg==1.21.6 + # via -r requirements/base.txt +edx-auth-backends==4.1.0 + # via -r requirements/base.txt +edx-braze-client==0.1.6 # via # -r requirements/base.txt # edx-ecommerce-worker @@ -313,7 +310,7 @@ edx-django-release-util==1.2.0 # via -r requirements/base.txt edx-django-sites-extensions==4.0.0 # via -r requirements/base.txt -edx-django-utils==5.0.1 +edx-django-utils==5.5.0 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -321,14 +318,13 @@ edx-django-utils==5.0.1 # edx-drf-extensions # edx-rest-api-client # getsmarter-api-clients -edx-drf-extensions==6.6.0 +edx-drf-extensions==8.8.0 # via - # -c requirements/constraints.txt # -r requirements/base.txt # edx-rbac -edx-ecommerce-worker==3.3.2 +edx-ecommerce-worker==3.3.4 # via -r requirements/base.txt -edx-i18n-tools==0.9.1 +edx-i18n-tools==0.9.2 # via -r requirements/test.in edx-opaque-keys==2.3.0 # via @@ -336,7 +332,7 @@ edx-opaque-keys==2.3.0 # edx-drf-extensions edx-rbac==1.7.0 # via -r requirements/base.txt -edx-rest-api-client==5.5.0 +edx-rest-api-client==5.5.2 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -345,68 +341,78 @@ enum34==1.1.10 # via # -r requirements/base.txt # cybersource-rest-client-python +exceptiongroup==1.1.1 + # via + # -r requirements/e2e.txt + # pytest extras==1.0.0 # via # -r requirements/base.txt # cybersource-rest-client-python # python-subunit - # testtools factory-boy==2.12.0 # via # -r requirements/base.txt # -r requirements/test.in # django-oscar -faker==14.2.0 +faker==18.10.1 # via # -r requirements/base.txt # factory-boy -filelock==3.8.0 +filelock==3.12.2 # via # -r requirements/tox.txt # tox -fixtures==3.0.0 +fixtures==4.1.0 # via # -r requirements/base.txt # cybersource-rest-client-python # testtools freezegun==1.2.2 # via -r requirements/test.in +frozenlist==1.3.3 + # via + # -r requirements/base.txt + # aiohttp + # aiosignal funcsigs==1.0.2 # via # -r requirements/base.txt # cybersource-rest-client-python -future==0.18.2 +future==0.18.3 # via - # -r requirements/base.txt + # -r requirements/e2e.txt # pyjwkest -getsmarter-api-clients==0.4.0 +getsmarter-api-clients==0.6.0 # via -r requirements/base.txt -google-api-core==1.30.0 +google-api-core==2.11.1 # via # -r requirements/base.txt # google-api-python-client google-api-python-client==2.31.0 - # via -r requirements/base.in -google-auth-httplib2==0.1.0 # via # -r requirements/base.txt - # google-api-python-client -google-auth==1.32.1 + # inapppy +google-auth==2.20.0 # via # -r requirements/base.txt # google-api-core # google-api-python-client # google-auth-httplib2 -googleapis-common-protos==1.53.0 +google-auth-httplib2==0.1.0 + # via + # -r requirements/base.txt + # google-api-python-client +googleapis-common-protos==1.59.1 # via # -r requirements/base.txt # google-api-core httplib2==0.20.2 - # via -r requirements/base.in -httpretty==0.9.7 # via - # -c requirements/pins.txt - # -r requirements/test.in + # -r requirements/base.txt + # google-api-python-client + # google-auth-httplib2 + # oauth2client idna==2.7 # via # -c requirements/constraints.txt @@ -414,21 +420,22 @@ idna==2.7 # -r requirements/e2e.txt # cybersource-rest-client-python # requests -importlib-metadata==4.12.0 + # yarl +importlib-metadata==6.7.0 # via # -r requirements/e2e.txt # pytest-randomly -inapppy==2.5.2 - # via -r requirements/base.txt -importlib-resources==5.9.0 +importlib-resources==5.12.0 # via # -r requirements/base.txt # jsonschema +inapppy==2.5.2 + # via -r requirements/base.txt inflection==0.5.1 # via # -r requirements/base.txt # drf-yasg -iniconfig==1.1.1 +iniconfig==2.0.0 # via # -r requirements/e2e.txt # pytest @@ -440,7 +447,7 @@ isodate==0.6.1 # via # -r requirements/base.txt # zeep -isort==5.10.1 +isort==5.12.0 # via # -r requirements/test.in # pylint @@ -460,7 +467,9 @@ jmespath==1.0.1 # botocore jsonfield==3.1.0 # via -r requirements/base.txt -jsonschema==4.16.0 +jsonfield2==4.0.0.post0 + # via -r requirements/base.txt +jsonschema==4.17.3 # via # -r requirements/base.txt # cybersource-rest-client-python @@ -468,9 +477,9 @@ kombu==4.6.11 # via # -r requirements/base.txt # celery -lazy==1.4 +lazy==1.5 # via bok-choy -lazy-object-proxy==1.7.1 +lazy-object-proxy==1.9.0 # via astroid libsass==0.9.2 # via @@ -485,7 +494,7 @@ logger==1.4 # via # -r requirements/base.txt # cybersource-rest-client-python -lxml==4.9.1 +lxml==4.9.2 # via # -r requirements/base.txt # -r requirements/test.in @@ -493,7 +502,7 @@ lxml==4.9.1 # zeep markdown==2.6.9 # via -r requirements/base.txt -markupsafe==2.1.1 +markupsafe==2.1.3 # via # -r requirements/base.txt # jinja2 @@ -501,31 +510,27 @@ mccabe==0.6.1 # via # -c requirements/constraints.txt # pylint -mock==4.0.3 +mock==5.0.2 # via -r requirements/test.in monotonic==1.6 # via # -r requirements/base.txt # analytics-python -more-itertools==8.8.0 - # via - # -r requirements/e2e.txt - # pytest -multidict==5.1.0 +multidict==6.0.4 # via # -r requirements/base.txt # aiohttp # yarl mysqlclient==1.4.6 # via -r requirements/base.txt -naked==0.1.31 +naked==0.1.32 # via # -r requirements/base.txt # crypto # cybersource-rest-client-python ndg-httpsclient==0.5.1 # via -r requirements/base.txt -newrelic==8.1.0.180 +newrelic==8.8.0 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -538,37 +543,31 @@ oauth2client==4.1.3 # via # -r requirements/base.txt # inapppy -oauthlib==3.2.1 +oauthlib==3.2.2 # via # -r requirements/base.txt # getsmarter-api-clients # requests-oauthlib # social-auth-core -openapi-codec==1.3.2 - # via - # -r requirements/base.txt - # django-rest-swagger -packaging==21.3 +packaging==23.1 # via # -r requirements/base.txt # -r requirements/e2e.txt # -r requirements/tox.txt # drf-yasg - # google-api-core # pytest - # redis # tox -paramiko==2.11.0 +paramiko==3.2.0 # via # -r requirements/base.txt # cybersource-rest-client-python -path==16.4.0 +path==16.6.0 # via edx-i18n-tools path-py==7.2 # via -r requirements/base.txt paypalrestsdk==1.13.1 # via -r requirements/base.txt -pbr==5.10.0 +pbr==5.11.1 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -576,11 +575,11 @@ pbr==5.10.0 # fixtures # stevedore # testtools -phonenumbers==8.12.55 +phonenumbers==8.13.14 # via # -r requirements/base.txt # django-oscar -pillow==9.2.0 +pillow==9.5.0 # via # -r requirements/base.txt # django-oscar @@ -588,7 +587,7 @@ pkgutil-resolve-name==1.3.10 # via # -r requirements/base.txt # jsonschema -platformdirs==2.5.2 +platformdirs==3.6.0 # via # -r requirements/base.txt # pylint @@ -601,16 +600,16 @@ pluggy==0.13.1 # diff-cover # pytest # tox -polib==1.1.1 +polib==1.2.0 # via edx-i18n-tools premailer==2.9.2 # via -r requirements/base.txt -protobuf==3.17.3 +protobuf==4.23.3 # via # -r requirements/base.txt # google-api-core # googleapis-common-protos -psutil==5.9.2 +psutil==5.9.5 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -623,14 +622,9 @@ py==1.11.0 # via # -r requirements/e2e.txt # -r requirements/tox.txt - # pytest + # pytest-html # tox -pyasn1-modules==0.2.8 - # via - # -r requirements/base.txt - # google-auth - # oauth2client -pyasn1==0.4.8 +pyasn1==0.5.0 # via # -r requirements/base.txt # cybersource-rest-client-python @@ -639,7 +633,12 @@ pyasn1==0.4.8 # pyasn1-modules # rsa # x509 -pycodestyle==2.9.1 +pyasn1-modules==0.3.0 + # via + # -r requirements/base.txt + # google-auth + # oauth2client +pycodestyle==2.10.0 # via -r requirements/test.in pycountry==17.1.8 # via -r requirements/base.txt @@ -647,39 +646,41 @@ pycparser==2.21 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cffi # cybersource-rest-client-python -pycryptodome==3.15.0 +pycryptodome==3.18.0 # via # -r requirements/base.txt # cybersource-rest-client-python -pycryptodomex==3.15.0 +pycryptodomex==3.18.0 # via # -r requirements/base.txt + # -r requirements/e2e.txt # cybersource-rest-client-python # pyjwkest -pygments==2.13.0 +pygments==2.15.1 # via # -r requirements/base.txt # diff-cover pyjwkest==1.4.2 - # via - # -r requirements/base.txt - # edx-drf-extensions -pyjwt[crypto]==1.7.1 + # via -r requirements/e2e.txt +pyjwt[crypto]==2.7.0 # via # -r requirements/base.txt # -r requirements/e2e.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # drf-jwt # edx-auth-backends + # edx-drf-extensions # edx-rest-api-client # social-auth-core pylint==2.12.2 # via # -c requirements/constraints.txt # -r requirements/test.in -pymongo==3.12.3 +pymongo==3.13.0 # via # -r requirements/base.txt # edx-opaque-keys @@ -690,28 +691,26 @@ pynacl==1.5.0 # cybersource-rest-client-python # edx-django-utils # paramiko -pyopenssl==22.0.0 +pyopenssl==23.2.0 # via # -r requirements/base.txt + # app-store-notifications-v2-validator # cybersource-rest-client-python # ndg-httpsclient # paypalrestsdk -pyparsing==3.0.9 +pyparsing==3.1.0 # via # -r requirements/base.txt - # -r requirements/e2e.txt - # -r requirements/tox.txt # httplib2 - # packaging pypi==2.1 # via # -r requirements/base.txt # cybersource-rest-client-python -pyrsistent==0.18.1 +pyrsistent==0.19.3 # via # -r requirements/base.txt # jsonschema -pytest==6.2.5 +pytest==7.3.2 # via # -r requirements/e2e.txt # -r requirements/test.in @@ -724,30 +723,33 @@ pytest==6.2.5 # pytest-selenium # pytest-timeout # pytest-variables -pytest-base-url==1.4.2 +pytest-base-url==2.0.0 # via # -r requirements/e2e.txt # pytest-selenium -pytest-cov==3.0.0 +pytest-cov==4.1.0 # via -r requirements/test.in pytest-django==4.5.2 # via -r requirements/test.in -pytest-html==3.1.1 +pytest-html==3.2.0 # via # -r requirements/e2e.txt # pytest-selenium -pytest-metadata==2.0.2 +pytest-metadata==3.0.0 # via # -r requirements/e2e.txt # pytest-html pytest-randomly==3.12.0 # via -r requirements/e2e.txt -pytest-selenium==3.0.0 - # via -r requirements/e2e.txt +pytest-selenium==2.0.1 + # via + # -c requirements/constraints.txt + # -r requirements/e2e.txt pytest-timeout==2.1.0 # via -r requirements/e2e.txt -pytest-variables==1.9.0 +pytest-variables==2.0.0 # via + # -c requirements/constraints.txt # -r requirements/e2e.txt # pytest-selenium python-dateutil==2.8.2 @@ -758,7 +760,7 @@ python-dateutil==2.8.2 # edx-drf-extensions # faker # freezegun -python-dotenv==0.21.0 +python-dotenv==1.0.0 # via -r requirements/e2e.txt python-memcached==1.59 # via -r requirements/test.in @@ -766,7 +768,7 @@ python-mimeparse==1.6.0 # via # -r requirements/base.txt # cybersource-rest-client-python -python-subunit==1.4.0 +python-subunit==1.4.2 # via # -r requirements/base.txt # cybersource-rest-client-python @@ -778,9 +780,8 @@ python3-openid==3.2.0 # via # -r requirements/base.txt # social-auth-core -pytz==2016.10 +pytz==2023.3 # via - # -c requirements/constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt # babel @@ -790,25 +791,27 @@ pytz==2016.10 # django # djangorestframework # djangorestframework-datatables + # drf-yasg # getsmarter-api-clients - # google-api-core # zeep pyyaml==6.0 # via # -r requirements/base.txt # cybersource-rest-client-python + # drf-yasg # edx-django-release-util # edx-i18n-tools # naked -rcssmin==1.1.0 + # responses +rcssmin==1.1.1 # via # -r requirements/base.txt # django-compressor -redis==4.3.4 +redis==4.5.5 # via # -r requirements/base.txt # edx-ecommerce-worker -requests==2.28.1 +requests==2.31.0 # via # -r requirements/base.txt # -r requirements/e2e.txt @@ -841,17 +844,13 @@ requests-oauthlib==1.3.1 # -r requirements/base.txt # getsmarter-api-clients # social-auth-core -requests-toolbelt==0.9.1 +requests-toolbelt==1.0.0 # via # -r requirements/base.txt # zeep -responses==0.21.0 +responses==0.23.1 # via -r requirements/test.in -rest-condition==1.0.3 - # via - # -r requirements/base.txt - # edx-drf-extensions -rjsmin==1.2.0 +rjsmin==1.2.1 # via # -r requirements/base.txt # django-compressor @@ -862,17 +861,9 @@ rsa==4.9 # google-auth # inapppy # oauth2client -ruamel-yaml==0.17.21 - # via - # -r requirements/base.txt - # drf-yasg -ruamel-yaml-clib==0.2.6 - # via - # -r requirements/base.txt - # ruamel-yaml rules==3.3 # via -r requirements/base.txt -s3transfer==0.6.0 +s3transfer==0.6.1 # via # -r requirements/base.txt # boto3 @@ -892,7 +883,7 @@ shellescape==3.8.1 # -r requirements/base.txt # crypto # cybersource-rest-client-python -simplejson==3.17.6 +simplejson==3.19.1 # via -r requirements/base.txt six==1.16.0 # via @@ -901,7 +892,6 @@ six==1.16.0 # -r requirements/tox.txt # analytics-python # bleach - # bok-choy # cybersource-rest-client-python # django-extra-views # djangorestframework-csv @@ -910,23 +900,17 @@ six==1.16.0 # edx-drf-extensions # edx-ecommerce-worker # edx-rbac - # google-api-core - # google-api-python-client # google-auth # google-auth-httplib2 # isodate # libsass # oauth2client - # paramiko # paypalrestsdk - # protobuf # purl # pyjwkest # python-dateutil # python-memcached # requests-file - # social-auth-app-django - # social-auth-core # tenacity # tox slumber==0.7.1 @@ -934,57 +918,56 @@ slumber==0.7.1 # -r requirements/base.txt # -r requirements/e2e.txt # edx-rest-api-client -social-auth-app-django==4.0.0 +social-auth-app-django==5.2.0 # via - # -c requirements/constraints.txt # -r requirements/base.txt # edx-auth-backends -social-auth-core==4.0.2 +social-auth-core==4.4.2 # via - # -c requirements/constraints.txt # -r requirements/base.txt # edx-auth-backends # social-auth-app-django sorl-thumbnail==12.9.0 # via -r requirements/base.txt -soupsieve==2.3.2.post1 +soupsieve==2.4.1 # via beautifulsoup4 -sqlparse==0.4.2 +sqlparse==0.4.4 # via # -r requirements/base.txt # -r requirements/e2e.txt # django -stevedore==4.0.0 +stevedore==5.1.0 # via # -r requirements/base.txt # -r requirements/e2e.txt # edx-django-utils # edx-opaque-keys -stripe==4.1.0 +stripe==5.4.0 # via -r requirements/base.txt tenacity==6.3.1 # via # -r requirements/e2e.txt # pytest-selenium -testfixtures==7.0.0 +testfixtures==7.1.0 # via -r requirements/test.in -testtools==2.5.0 +testtools==2.6.0 # via # -r requirements/base.txt # cybersource-rest-client-python # python-subunit toml==0.10.2 # via - # -r requirements/e2e.txt # -r requirements/tox.txt # pylint - # pytest - # pytest-cov # tox tomli==2.0.1 - # via coverage + # via + # -r requirements/e2e.txt + # coverage + # pytest tox==3.14.6 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/tox.txt # tox-battery @@ -994,14 +977,17 @@ traceback2==1.4.0 # via # -r requirements/base.txt # cybersource-rest-client-python - # testtools - # unittest2 +types-pyyaml==6.0.12.10 + # via responses typing==3.7.4.3 # via # -r requirements/base.txt # cybersource-rest-client-python -typing-extensions==4.3.0 +typing-extensions==4.6.3 # via + # -r requirements/base.txt + # -r requirements/e2e.txt + # asgiref # astroid # pylint unicodecsv==0.14.1 @@ -1013,13 +999,15 @@ uritemplate==4.1.1 # -r requirements/base.txt # coreapi # drf-yasg -urllib3==1.26.12 + # google-api-python-client +urllib3==1.26.16 # via # -c requirements/constraints.txt # -r requirements/base.txt # -r requirements/e2e.txt # botocore # cybersource-rest-client-python + # google-auth # requests # responses # selenium @@ -1043,35 +1031,33 @@ webob==1.8.7 # via webtest webtest==3.0.0 # via django-webtest -wheel==0.37.1 +wheel==0.40.0 # via # -r requirements/base.txt # cybersource-rest-client-python wrapt==1.13.3 # via # -c requirements/constraints.txt - # -r requirements/base.txt # astroid - # deprecated x509==0.1 # via # -r requirements/base.txt # cybersource-rest-client-python xss-utils==0.4.0 # via -r requirements/base.txt -yarl==1.6.3 +yarl==1.9.2 # via # -r requirements/base.txt # aiohttp -zeep==4.1.0 +zeep==4.2.1 # via -r requirements/base.txt -zipp==3.8.1 +zipp==3.15.0 # via # -r requirements/base.txt # -r requirements/e2e.txt # importlib-metadata # importlib-resources -zope-interface==5.4.0 +zope-interface==6.0 # via # -r requirements/base.txt # cybersource-rest-client-python diff --git a/requirements/tox.txt b/requirements/tox.txt index 128d74bc661..7721aa931aa 100644 --- a/requirements/tox.txt +++ b/requirements/tox.txt @@ -1,12 +1,12 @@ # -# This file is autogenerated by pip-compile with python 3.8 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.8 +# by the following command: # # make upgrade # -filelock==3.8.0 +filelock==3.12.2 # via tox -packaging==21.3 +packaging==23.1 # via tox pluggy==0.13.1 # via @@ -14,14 +14,13 @@ pluggy==0.13.1 # tox py==1.11.0 # via tox -pyparsing==3.0.9 - # via packaging six==1.16.0 # via tox toml==0.10.2 # via tox tox==3.14.6 # via + # -c requirements/common_constraints.txt # -c requirements/constraints.txt # -r requirements/tox.in # tox-battery diff --git a/setup.cfg b/setup.cfg index e69362b4b18..10119aebaf5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,3 +6,6 @@ ignore=E501,E722,W504 max_line_length=119 exclude=settings,migrations,ecommerce/static,bower_components,ecommerce/wsgi.py + +[flake8] +max-line-length=119