diff --git a/RELEASE.rst b/RELEASE.rst
index b22aadc6e..5ec2c1c76 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,18 @@
Release Notes
=============
+Version 0.5.1
+-------------
+
+- Fixed encrypted response getting ascii-escaped
+- add feature site nofication through cms (#309)
+- Added hubspot ecommerce bridge (#276)
+- Move Header Bundle back to Original Location
+- Use query parameters when loading checkout page (#283)
+- Fix coupon apply button bug (#296)
+- Added SDN compliance api and data model
+- Convert Sections to Generic
+
Version 0.5.0 (Released May 22, 2019)
-------------
diff --git a/app.json b/app.json
index 024116407..749c866f5 100644
--- a/app.json
+++ b/app.json
@@ -35,9 +35,17 @@
"CYBERSOURCE_ACCESS_KEY": {
"description": "CyberSource Access Key"
},
+ "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY": {
+ "description": "The public key to encrypt export results with for our own security purposes. Should be a base64 encoded NaCl public key.",
+ "required": false
+ },
"CYBERSOURCE_PROFILE_ID": {
"description": "CyberSource Profile ID"
},
+ "CYBERSOURCE_MERCHANT_ID": {
+ "description": "The cybersource merchant id",
+ "required": false
+ },
"CYBERSOURCE_REFERENCE_PREFIX": {
"description": "a string prefix to identify the application in CyberSource transactions"
},
@@ -47,6 +55,14 @@
"CYBERSOURCE_SECURITY_KEY": {
"description": "CyberSource API key"
},
+ "CYBERSOURCE_TRANSACTION_KEY": {
+ "description": "The cybersource transaction key",
+ "required": false
+ },
+ "CYBERSOURCE_WSDL_URL": {
+ "description": "The URL to the cybersource WSDL",
+ "required": false
+ },
"GA_TRACKING_ID": {
"description": "Google analytics tracking ID",
"required": false
diff --git a/authentication/exceptions.py b/authentication/exceptions.py
index d00854d7f..1dc392e7f 100644
--- a/authentication/exceptions.py
+++ b/authentication/exceptions.py
@@ -76,5 +76,13 @@ class UnexpectedExistingUserException(PartialException):
"""Raised if a user already exists but shouldn't in the given pipeline step"""
+class UserExportBlockedException(AuthException):
+ """The user is blocked for export reasons from continuing to sign up"""
+
+
+class UserTryAgainLaterException(AuthException):
+ """The user should try to register again later"""
+
+
class UserMissingSocialAuthException(Exception):
"""Raised if the user doesn't have a social auth"""
diff --git a/authentication/pipeline/compliance.py b/authentication/pipeline/compliance.py
new file mode 100644
index 000000000..7d64990d0
--- /dev/null
+++ b/authentication/pipeline/compliance.py
@@ -0,0 +1,70 @@
+"""Compliance pipeline actions"""
+import logging
+
+from django.conf import settings
+from django.core import mail
+from social_core.exceptions import AuthException
+
+from authentication.exceptions import (
+ UserExportBlockedException,
+ UserTryAgainLaterException,
+)
+from compliance import api
+
+
+log = logging.getLogger()
+
+
+def verify_exports_compliance(
+ strategy, backend, user=None, **kwargs
+): # pylint: disable=unused-argument
+ """
+ Verify that the user is allowed by exports compliance
+
+ Args:
+ strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
+ backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
+ user (User): the current user
+ """
+ if not api.is_exports_verification_enabled():
+ log.warning("Export compliance checks are disabled")
+ return {}
+
+ # skip this step if the user is active or they have an existing export inquiry logged
+ if user.is_active and user.exports_inquiries.exists():
+ return {}
+
+ try:
+ export_inquiry = api.verify_user_with_exports(user)
+ except Exception as exc: # pylint: disable=broad-except
+ # hard failure to request the exports API, log an error but don't let the user proceed
+ log.exception("Unable to verify exports compliance")
+ raise UserTryAgainLaterException(backend) from exc
+
+ if export_inquiry is None:
+ raise UserTryAgainLaterException(backend)
+ elif export_inquiry.is_denied:
+ log.info(
+ "User with email '%s' was denied due to exports violation, for reason_code=%s, info_code=%s",
+ user.email,
+ export_inquiry.reason_code,
+ export_inquiry.info_code,
+ )
+ try:
+ with mail.get_connection(settings.NOTIFICATION_EMAIL_BACKEND) as connection:
+ mail.send_mail(
+ f"Exports Compliance: denied {user.email}",
+ f"User with email '{user.email}' was denied due to exports violation, for reason_code={export_inquiry.reason_code}, info_code={export_inquiry.info_code}",
+ settings.MAILGUN_FROM_EMAIL,
+ [settings.EMAIL_SUPPORT],
+ connection=connection,
+ )
+ except Exception: # pylint: disable=broad-except
+ log.exception(
+ "Exception sending email to support regarding export compliance check failure"
+ )
+ raise UserExportBlockedException(backend)
+ elif export_inquiry.is_unknown:
+ raise AuthException("Unable to authenticate, please contact support")
+
+ return {}
diff --git a/authentication/pipeline/compliance_test.py b/authentication/pipeline/compliance_test.py
new file mode 100644
index 000000000..d6b2d3809
--- /dev/null
+++ b/authentication/pipeline/compliance_test.py
@@ -0,0 +1,110 @@
+"""Compliance pipeline tests"""
+import pytest
+from social_core.exceptions import AuthException
+
+from authentication.exceptions import (
+ UserExportBlockedException,
+ UserTryAgainLaterException,
+)
+from authentication.pipeline import compliance
+from compliance.factories import ExportsInquiryLogFactory
+
+
+pytestmark = pytest.mark.django_db
+
+
+def test_verify_exports_compliance_disabled(mocker):
+ """Assert that nothing is done when the api is disabled"""
+ mock_api = mocker.patch("authentication.pipeline.compliance.api")
+ mock_api.is_exports_verification_enabled.return_value = False
+
+ assert compliance.verify_exports_compliance(None, None) == {}
+
+
+@pytest.mark.parametrize(
+ "is_active, inquiry_exists, should_verify",
+ [
+ [True, True, False],
+ [True, False, True],
+ [False, True, True],
+ [False, False, True],
+ ],
+)
+def test_verify_exports_compliance_user_active(
+ mailoutbox, mocker, user, is_active, inquiry_exists, should_verify
+): # pylint: disable=too-many-arguments
+ """Assert that the user is verified only if they already haven't been"""
+ user.is_active = is_active
+ if inquiry_exists:
+ ExportsInquiryLogFactory.create(user=user)
+
+ mock_api = mocker.patch("authentication.pipeline.compliance.api")
+ mock_api.verify_user_with_exports.return_value = mocker.Mock(
+ is_denied=False, is_unknown=False
+ )
+
+ assert compliance.verify_exports_compliance(None, None, user=user) == {}
+
+ if should_verify:
+ mock_api.verify_user_with_exports.assert_called_once_with(user)
+ assert len(mailoutbox) == 0
+
+
+def test_verify_exports_compliance_no_record(mocker, user):
+ """Assert that an error to try again later is raised if no ExportsInquiryLog is created"""
+
+ mock_api = mocker.patch("authentication.pipeline.compliance.api")
+ mock_api.verify_user_with_exports.return_value = None
+
+ with pytest.raises(UserTryAgainLaterException):
+ compliance.verify_exports_compliance(None, None, user=user)
+
+ mock_api.verify_user_with_exports.assert_called_once_with(user)
+
+
+def test_verify_exports_compliance_api_raises_exception(mocker, user):
+ """Assert that an error to try again later is raised if the export api raises an exception"""
+
+ mock_api = mocker.patch("authentication.pipeline.compliance.api")
+ mock_api.verify_user_with_exports.side_effect = Exception("error")
+
+ with pytest.raises(UserTryAgainLaterException):
+ compliance.verify_exports_compliance(None, None, user=user)
+
+ mock_api.verify_user_with_exports.assert_called_once_with(user)
+
+
+@pytest.mark.parametrize("email_fails", [True, False])
+def test_verify_exports_compliance_denied(mailoutbox, mocker, user, email_fails):
+ """Assert that a UserExportBlockedException is raised if the inquiry result is denied"""
+ mock_api = mocker.patch("authentication.pipeline.compliance.api")
+ mock_api.verify_user_with_exports.return_value = mocker.Mock(
+ is_denied=True, is_unknown=False, reason_code=100, info_code="123"
+ )
+
+ if email_fails:
+ # a mail sending error should not obscurve the true error
+ mocker.patch(
+ "authentication.pipeline.compliance.mail.send_mail",
+ side_effect=Exception("mail error"),
+ )
+
+ with pytest.raises(UserExportBlockedException):
+ compliance.verify_exports_compliance(None, None, user=user)
+
+ mock_api.verify_user_with_exports.assert_called_once_with(user)
+ assert len(mailoutbox) == (0 if email_fails else 1)
+
+
+def test_verify_exports_compliance_unknown(mailoutbox, mocker, user):
+ """Assert that a UserExportBlockedException is raised if the inquiry result is unknown"""
+ mock_api = mocker.patch("authentication.pipeline.compliance.api")
+ mock_api.verify_user_with_exports.return_value = mocker.Mock(
+ is_denied=False, is_unknown=True
+ )
+
+ with pytest.raises(AuthException):
+ compliance.verify_exports_compliance(None, None, user=user)
+
+ mock_api.verify_user_with_exports.assert_called_once_with(user)
+ assert len(mailoutbox) == 0
diff --git a/authentication/pipeline/user.py b/authentication/pipeline/user.py
index 8d85efa3b..e7f91b723 100644
--- a/authentication/pipeline/user.py
+++ b/authentication/pipeline/user.py
@@ -14,6 +14,8 @@
UnexpectedExistingUserException,
)
from authentication.utils import SocialAuthState
+
+from compliance import api as compliance_api
from users.serializers import UserSerializer, ProfileSerializer
# pylint: disable=keyword-arg-before-vararg
@@ -176,10 +178,34 @@ def forbid_hijack(strategy, backend, **kwargs): # pylint: disable=unused-argume
Args:
strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
- user (User): the current user
- flow (str): the type of flow (login or register)
"""
# As first step in pipeline, stop a hijacking admin from going any further
if strategy.session_get("is_hijacked_user"):
raise AuthException("You are hijacking another user, don't try to login again")
return {}
+
+
+def activate_user(
+ strategy, backend, user=None, is_new=False, **kwargs
+): # pylint: disable=unused-argument
+ """
+ Activate the user's account if they passed export controls
+
+ Args:
+ strategy (social_django.strategy.DjangoStrategy): the strategy used to authenticate
+ backend (social_core.backends.base.BaseAuth): the backend being used to authenticate
+ user (User): the current user
+ """
+ if user.is_active or not is_new:
+ return {}
+
+ export_inquiry = user.exports_inquiries.order_by("-created_on").first()
+
+ # if the user has an export inquiry that is considered successful, activate them
+ if not compliance_api.is_exports_verification_enabled() or (
+ export_inquiry is not None and export_inquiry.is_success
+ ):
+ user.is_active = True
+ user.save()
+
+ return {}
diff --git a/authentication/pipeline/user_test.py b/authentication/pipeline/user_test.py
index 3a1908041..bda4cec20 100644
--- a/authentication/pipeline/user_test.py
+++ b/authentication/pipeline/user_test.py
@@ -18,6 +18,8 @@
RequireUserException,
)
from authentication.utils import SocialAuthState
+from compliance.constants import RESULT_SUCCESS, RESULT_DENIED, RESULT_UNKNOWN
+from compliance.factories import ExportsInquiryLogFactory
@pytest.fixture
@@ -371,3 +373,35 @@ def test_forbid_hijack(mocker, hijacked):
user_actions.forbid_hijack(*args, **kwargs)
else:
assert user_actions.forbid_hijack(*args, **kwargs) == {}
+
+
+@pytest.mark.parametrize("is_active", [True, False])
+@pytest.mark.parametrize("is_new", [True, False])
+@pytest.mark.parametrize(
+ "is_enabled, has_inquiry, computed_result, expected",
+ [
+ [True, True, RESULT_SUCCESS, True], # feature enabled, result is success
+ [True, True, RESULT_DENIED, False], # feature enabled, result is denied
+ [True, True, RESULT_UNKNOWN, False], # feature enabled, result is unknown
+ [False, False, None, True], # feature disabled
+ [True, False, None, False], # feature enabled, no result
+ ],
+)
+def test_activate_user(
+ mocker, user, is_active, is_new, is_enabled, has_inquiry, computed_result, expected
+): # pylint: disable=too-many-arguments
+ """Test that activate_user takes the correct action"""
+ user.is_active = is_active
+ if has_inquiry:
+ ExportsInquiryLogFactory.create(user=user, computed_result=computed_result)
+
+ mocker.patch(
+ "authentication.pipeline.user.compliance_api.is_exports_verification_enabled",
+ return_value=is_enabled,
+ )
+
+ assert user_actions.activate_user(None, None, user=user, is_new=is_new) == {}
+
+ if not user.is_active and is_new:
+ # only if the user is inactive and just registered
+ assert user.is_active is expected
diff --git a/authentication/serializers.py b/authentication/serializers.py
index c595a58d6..ef7f564b2 100644
--- a/authentication/serializers.py
+++ b/authentication/serializers.py
@@ -21,6 +21,8 @@
RequireProviderException,
RequireRegistrationException,
RequireProfileException,
+ UserExportBlockedException,
+ UserTryAgainLaterException,
)
from authentication.utils import SocialAuthState
@@ -284,6 +286,17 @@ def create(self, validated_data):
result = SocialAuthState(
SocialAuthState.STATE_REGISTER_EXTRA_DETAILS, partial=exc.partial
)
+ except UserExportBlockedException:
+ result = SocialAuthState(
+ SocialAuthState.STATE_USER_BLOCKED,
+ errors=["Unable to complete registration, please contact support"],
+ )
+ except UserTryAgainLaterException:
+ result = SocialAuthState(
+ SocialAuthState.STATE_ERROR_TEMPORARY,
+ errors=["Unable to register at this time, please try again later"],
+ )
+
return result
diff --git a/authentication/utils.py b/authentication/utils.py
index 4ebdd6120..1875dc173 100644
--- a/authentication/utils.py
+++ b/authentication/utils.py
@@ -24,8 +24,10 @@ class SocialAuthState:
# end states
STATE_SUCCESS = "success"
STATE_ERROR = "error"
+ STATE_ERROR_TEMPORARY = "error-temporary"
STATE_INACTIVE = "inactive"
STATE_INVALID_EMAIL = "invalid-email"
+ STATE_USER_BLOCKED = "user-blocked"
def __init__(
self,
diff --git a/authentication/views_test.py b/authentication/views_test.py
index 3a3f98473..08c9a7402 100644
--- a/authentication/views_test.py
+++ b/authentication/views_test.py
@@ -1,16 +1,41 @@
"""Tests for authentication views"""
# pylint: disable=redefined-outer-name
+from contextlib import contextmanager, ExitStack
+from unittest.mock import patch
+from django.conf import settings
from django.contrib.auth import get_user, get_user_model
+from django.core import mail
+from django.db import transaction
from django.urls import reverse
+from django.test import Client, override_settings
import factory
+from faker import Faker
+from hypothesis import settings as hypothesis_settings, strategies as st, Verbosity
+from hypothesis.stateful import (
+ consumes,
+ precondition,
+ rule,
+ Bundle,
+ RuleBasedStateMachine,
+ HealthCheck,
+)
+from hypothesis.extra.django import TestCase as HTestCase
import pytest
+import responses
from rest_framework import status
from social_core.backends.email import EmailAuth
from authentication.serializers import PARTIAL_PIPELINE_TOKEN_KEY
from authentication.utils import SocialAuthState
-from users.factories import UserSocialAuthFactory
+from compliance.constants import RESULT_DENIED, RESULT_SUCCESS
+from compliance.models import ExportsInquiryLog
+from compliance.test_utils import (
+ get_cybersource_test_settings,
+ mock_cybersource_wsdl,
+ mock_cybersource_wsdl_operation,
+)
+from users.factories import UserFactory, UserSocialAuthFactory
from mitxpro.test_utils import any_instance_of, MockResponse
pytestmark = [pytest.mark.django_db]
@@ -20,6 +45,8 @@
User = get_user_model()
+fake = Faker()
+
@pytest.fixture
def email_user(user):
@@ -49,7 +76,7 @@ def assert_api_call(
"redirect_url": None,
"extra_data": {},
"state": None,
- "provider": None,
+ "provider": EmailAuth.name,
"flow": None,
"partial_token": any_instance_of(str),
}
@@ -68,233 +95,238 @@ def mock_email_send(mocker):
yield mocker.patch("mail.verification_api.send_verification_email")
-@pytest.fixture()
-def mock_recaptcha_success(mocker):
- """ Mock Google recaptcha request"""
- yield mocker.patch(
- "authentication.views.requests.post",
- return_value=MockResponse(
- content='{"success": true}', status_code=status.HTTP_200_OK
- ),
- )
+@contextmanager
+def export_check_response(response_name):
+ """Context manager for configuring export check responses"""
+ with override_settings(
+ **get_cybersource_test_settings()
+ ), responses.RequestsMock() as mocked_responses:
+ mock_cybersource_wsdl(mocked_responses, settings)
+ mock_cybersource_wsdl_operation(mocked_responses, response_name)
+ yield
-@pytest.fixture()
-def mock_recaptcha_failure(mocker):
- """ Mock Google recaptcha request"""
- yield mocker.patch(
+class AuthStateMachine(RuleBasedStateMachine):
+ """
+ State machine for auth flows
+
+ How to understand this code:
+
+ This code exercises our social auth APIs, which is basically a graph of nodes and edges that the user traverses.
+ You can understand the bundles defined below to be the nodes and the methods of this class to be the edges.
+
+ If you add a new state to the auth flows, create a new bundle to represent that state and define
+ methods to define transitions into and (optionally) out of that state.
+ """
+
+ ConfirmationSentAuthStates = Bundle("confirmation-sent")
+ ConfirmationRedeemedAuthStates = Bundle("confirmation-redeemed")
+ RegisterExtraDetailsAuthStates = Bundle("register-details-extra")
+
+ LoginPasswordAuthStates = Bundle("login-password")
+
+ recaptcha_patcher = patch(
"authentication.views.requests.post",
return_value=MockResponse(
- content='{"success": false, "error-codes": ["bad-request"]}',
- status_code=status.HTTP_200_OK,
+ content='{"success": true}', status_code=status.HTTP_200_OK
),
)
+ email_send_patcher = patch(
+ "mail.verification_api.send_verification_email", autospec=True
+ )
+ def __init__(self):
+ """Setup the machine"""
+ super().__init__()
+ # wrap the execution in a django transaction, similar to django's TestCase
+ self.atomic = transaction.atomic()
+ self.atomic.__enter__()
-@pytest.fixture()
-def login_email_exists(client, email_user):
- """Yield a function for this step"""
+ # wrap the execution in a patch()
+ self.mock_email_send = self.email_send_patcher.start()
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- return assert_api_call(
- client,
- "psa-login-email",
- {
- "flow": SocialAuthState.FLOW_LOGIN,
- "email": email_user.email,
- "next": NEXT_URL,
- },
- {
- "flow": SocialAuthState.FLOW_LOGIN,
- "provider": EmailAuth.name,
- "state": SocialAuthState.STATE_LOGIN_PASSWORD,
- "extra_data": {"name": email_user.name},
- },
- )
+ # django test client
+ self.client = Client()
- yield run_step
+ # shared data
+ self.email = fake.email()
+ self.user = None
+ # track whether we've hit an action that starts a flow or not
+ self.flow_started = False
-@pytest.fixture()
-def login_email_next(client, email_user):
- """Yield a function for this step"""
+ def teardown(self):
+ """Cleanup from a run"""
+ # clear the mailbox
+ del mail.outbox[:]
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- return assert_api_call(
- client,
- "psa-login-email",
- {
- "flow": SocialAuthState.FLOW_LOGIN,
- "email": email_user.email,
- "next": NEXT_URL,
- },
- {
- "flow": SocialAuthState.FLOW_LOGIN,
- "provider": EmailAuth.name,
- "state": SocialAuthState.STATE_LOGIN_PASSWORD,
- "extra_data": {"name": email_user.name},
- },
- )
-
- yield run_step
+ # stop the patch
+ self.email_send_patcher.stop()
+ # end the transaction with a rollback to cleanup any state
+ transaction.set_rollback(True)
+ self.atomic.__exit__(None, None, None)
-@pytest.fixture()
-def register_email_exists(client, user, mock_email_send):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- result = assert_api_call(
- client,
- "psa-register-email",
- {
- "flow": SocialAuthState.FLOW_REGISTER,
- "email": user.email,
- "next": NEXT_URL,
- },
- {
- "errors": ["Password is required to login"],
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
- "state": SocialAuthState.STATE_LOGIN_PASSWORD,
- },
+ def create_existing_user(self):
+ """Create an existing user"""
+ self.user = UserFactory.create(email=self.email)
+ UserSocialAuthFactory.create(
+ user=self.user, provider=EmailAuth.name, uid=self.user.email
)
- mock_email_send.assert_not_called()
- return result
-
- yield run_step
-
-@pytest.fixture()
-def login_email_not_exists(client):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- result = assert_api_call(
- client,
+ @rule(
+ target=ConfirmationSentAuthStates,
+ recaptcha_enabled=st.sampled_from([True, False]),
+ )
+ @precondition(lambda self: not self.flow_started)
+ def register_email_not_exists(self, recaptcha_enabled):
+ """Register email not exists"""
+ self.flow_started = True
+
+ with ExitStack() as stack:
+ mock_recaptcha_success = None
+ if recaptcha_enabled:
+ mock_recaptcha_success = stack.enter_context(self.recaptcha_patcher)
+ stack.enter_context(override_settings(**{"RECAPTCHA_SITE_KEY": "fake"}))
+ result = assert_api_call(
+ self.client,
+ "psa-register-email",
+ {
+ "flow": SocialAuthState.FLOW_REGISTER,
+ "email": self.email,
+ **({"recaptcha": "fake"} if recaptcha_enabled else {}),
+ },
+ {
+ "flow": SocialAuthState.FLOW_REGISTER,
+ "partial_token": None,
+ "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT,
+ },
+ )
+ self.mock_email_send.assert_called_once()
+ if mock_recaptcha_success:
+ mock_recaptcha_success.assert_called_once()
+ return result
+
+ @rule(
+ target=LoginPasswordAuthStates, recaptcha_enabled=st.sampled_from([True, False])
+ )
+ @precondition(lambda self: not self.flow_started)
+ def register_email_exists(self, recaptcha_enabled):
+ """Register email exists"""
+ self.flow_started = True
+ self.create_existing_user()
+
+ with ExitStack() as stack:
+ mock_recaptcha_success = None
+ if recaptcha_enabled:
+ mock_recaptcha_success = stack.enter_context(self.recaptcha_patcher)
+ stack.enter_context(override_settings(**{"RECAPTCHA_SITE_KEY": "fake"}))
+
+ result = assert_api_call(
+ self.client,
+ "psa-register-email",
+ {
+ "flow": SocialAuthState.FLOW_REGISTER,
+ "email": self.email,
+ "next": NEXT_URL,
+ **({"recaptcha": "fake"} if recaptcha_enabled else {}),
+ },
+ {
+ "flow": SocialAuthState.FLOW_REGISTER,
+ "state": SocialAuthState.STATE_LOGIN_PASSWORD,
+ "errors": ["Password is required to login"],
+ },
+ )
+ self.mock_email_send.assert_not_called()
+ if mock_recaptcha_success:
+ mock_recaptcha_success.assert_called_once()
+ return result
+
+ @rule()
+ @precondition(lambda self: not self.flow_started)
+ def register_email_not_exists_with_recaptcha_invalid(self):
+ """Yield a function for this step"""
+ self.flow_started = True
+ with patch(
+ "authentication.views.requests.post",
+ return_value=MockResponse(
+ content='{"success": false, "error-codes": ["bad-request"]}',
+ status_code=status.HTTP_200_OK,
+ ),
+ ) as mock_recaptcha_failure, override_settings(
+ **{"RECAPTCHA_SITE_KEY": "fakse"}
+ ):
+ assert_api_call(
+ self.client,
+ "psa-register-email",
+ {
+ "flow": SocialAuthState.FLOW_REGISTER,
+ "email": NEW_EMAIL,
+ "recaptcha": "fake",
+ },
+ {"error-codes": ["bad-request"], "success": False},
+ expect_status=status.HTTP_400_BAD_REQUEST,
+ use_defaults=False,
+ )
+ mock_recaptcha_failure.assert_called_once()
+ self.mock_email_send.assert_not_called()
+
+ @rule()
+ @precondition(lambda self: not self.flow_started)
+ def login_email_not_exists(self):
+ """Login for an email that doesn't exist"""
+ self.flow_started = True
+ assert_api_call(
+ self.client,
"psa-login-email",
- {"flow": SocialAuthState.FLOW_LOGIN, "email": NEW_EMAIL},
+ {"flow": SocialAuthState.FLOW_LOGIN, "email": self.email},
{
"errors": ["Couldn't find your account"],
"flow": SocialAuthState.FLOW_LOGIN,
- "provider": EmailAuth.name,
"partial_token": None,
"state": SocialAuthState.STATE_ERROR,
},
)
- assert User.objects.filter(email=NEW_EMAIL).exists() is False
- return result
-
- yield run_step
+ assert User.objects.filter(email=self.email).exists() is False
+ @rule(target=LoginPasswordAuthStates)
+ @precondition(lambda self: not self.flow_started)
+ def login_email_exists(self):
+ """Login with a user that exists"""
+ self.flow_started = True
+ self.create_existing_user()
-@pytest.fixture()
-def register_email_not_exists(client, mock_email_send):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- result = assert_api_call(
- client,
- "psa-register-email",
- {"flow": SocialAuthState.FLOW_REGISTER, "email": NEW_EMAIL},
- {
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
- "partial_token": None,
- "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT,
- },
- )
- mock_email_send.assert_called_once()
- assert User.objects.filter(email=NEW_EMAIL).exists() is False
- return result
-
- yield run_step
-
-
-@pytest.fixture()
-def register_email_not_exists_with_recaptcha(
- settings, client, mock_email_send, mock_recaptcha_success
-):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- settings.RECAPTCHA_SITE_KEY = "fake"
- result = assert_api_call(
- client,
- "psa-register-email",
- {
- "flow": SocialAuthState.FLOW_REGISTER,
- "email": NEW_EMAIL,
- "recaptcha": "fake",
- },
+ return assert_api_call(
+ self.client,
+ "psa-login-email",
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
- "partial_token": None,
- "state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT,
+ "flow": SocialAuthState.FLOW_LOGIN,
+ "email": self.user.email,
+ "next": NEXT_URL,
},
- )
- mock_recaptcha_success.assert_called_once()
- mock_email_send.assert_called_once()
- return result
-
- yield run_step
-
-
-@pytest.fixture()
-def register_email_not_exists_with_recaptcha_invalid(
- settings, client, mock_email_send, mock_recaptcha_failure
-):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- settings.RECAPTCHA_SITE_KEY = "fake"
- result = assert_api_call(
- client,
- "psa-register-email",
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "email": NEW_EMAIL,
- "recaptcha": "fake",
+ "flow": SocialAuthState.FLOW_LOGIN,
+ "state": SocialAuthState.STATE_LOGIN_PASSWORD,
+ "extra_data": {"name": self.user.name},
},
- {"error-codes": ["bad-request"], "success": False},
- expect_status=status.HTTP_400_BAD_REQUEST,
- use_defaults=False,
)
- mock_recaptcha_failure.assert_called_once()
- mock_email_send.assert_not_called()
- return result
-
- yield run_step
-
-@pytest.fixture()
-def login_password_valid(client, user):
- """Yield a function for this step"""
- password = "password1"
-
- def run_step(last_result):
- """Run the step"""
- user.set_password(password)
- user.save()
- return assert_api_call(
- client,
+ @rule(auth_state=consumes(LoginPasswordAuthStates))
+ def login_password_valid(self, auth_state):
+ """Login with an invalid password"""
+ password = "password123"
+ self.user.set_password(password)
+ self.user.save()
+ assert_api_call(
+ self.client,
"psa-login-password",
{
- "flow": SocialAuthState.FLOW_LOGIN,
- "partial_token": last_result["partial_token"],
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
"password": password,
},
{
- "flow": SocialAuthState.FLOW_LOGIN,
- "provider": EmailAuth.name,
+ "flow": auth_state["flow"],
"redirect_url": NEXT_URL,
"partial_token": None,
"state": SocialAuthState.STATE_SUCCESS,
@@ -302,130 +334,104 @@ def run_step(last_result):
expect_authenticated=True,
)
- yield run_step
-
-
-@pytest.fixture()
-def login_password_user_inactive(client, user):
- """Yield a function for this step"""
- password = "password1"
-
- def run_step(last_result):
- """Run the step"""
- user.is_active = False
- user.set_password(password)
- user.save()
+ @rule(target=LoginPasswordAuthStates, auth_state=consumes(LoginPasswordAuthStates))
+ def login_password_invalid(self, auth_state):
+ """Login with an invalid password"""
+ self.user.set_password("password1")
+ self.user.save()
return assert_api_call(
- client,
+ self.client,
"psa-login-password",
{
- "flow": SocialAuthState.FLOW_LOGIN,
- "partial_token": last_result["partial_token"],
- "password": password,
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
+ "password": "invalidpass",
},
{
- "flow": SocialAuthState.FLOW_LOGIN,
- "provider": EmailAuth.name,
- "partial_token": None,
- "state": SocialAuthState.STATE_INACTIVE,
+ "errors": ["Unable to login with that email and password combination"],
+ "flow": auth_state["flow"],
+ "state": SocialAuthState.STATE_ERROR,
},
)
- yield run_step
+ @rule(auth_state=consumes(LoginPasswordAuthStates))
+ def login_password_user_inactive(self, auth_state):
+ """Login for an inactive user"""
+ password = "password1"
+ self.user.is_active = False
+ self.user.set_password(password)
+ self.user.save()
-@pytest.fixture()
-def login_password_invalid(client, user):
- """Yield a function for this step"""
-
- def run_step(last_result):
- """Run the step"""
- user.set_password("password1")
- user.save()
- return assert_api_call(
- client,
+ assert_api_call(
+ self.client,
"psa-login-password",
{
- "flow": SocialAuthState.FLOW_LOGIN,
- "partial_token": last_result["partial_token"],
- "password": "invalidpass",
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
+ "password": password,
},
{
- "errors": ["Unable to login with that email and password combination"],
- "flow": SocialAuthState.FLOW_LOGIN,
- "provider": EmailAuth.name,
- "state": SocialAuthState.STATE_ERROR,
+ "flow": auth_state["flow"],
+ "partial_token": None,
+ "state": SocialAuthState.STATE_INACTIVE,
},
)
- yield run_step
-
-
-@pytest.fixture()
-def redeem_confirmation_code(client, mock_email_send):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- _, _, code, partial_token = mock_email_send.call_args[0]
+ @rule(
+ target=ConfirmationRedeemedAuthStates,
+ auth_state=consumes(ConfirmationSentAuthStates),
+ )
+ def redeem_confirmation_code(self, auth_state):
+ """Redeem a registration confirmation code"""
+ _, _, code, partial_token = self.mock_email_send.call_args[0]
return assert_api_call(
- client,
+ self.client,
"psa-register-confirm",
{
- "flow": SocialAuthState.FLOW_REGISTER,
+ "flow": auth_state["flow"],
"verification_code": code.code,
"partial_token": partial_token,
},
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
+ "flow": auth_state["flow"],
"state": SocialAuthState.STATE_REGISTER_DETAILS,
},
)
- yield run_step
-
-
-@pytest.fixture()
-def redeem_confirmation_code_twice(client, mock_email_send):
- """Yield a function for this step"""
-
- def run_step(last_result): # pylint: disable=unused-argument
- """Run the step"""
- _, _, code, partial_token = mock_email_send.call_args[0]
- return assert_api_call(
- client,
+ @rule(auth_state=consumes(ConfirmationRedeemedAuthStates))
+ def redeem_confirmation_code_twice(self, auth_state):
+ """Redeeming a code twice should fail"""
+ _, _, code, partial_token = self.mock_email_send.call_args[0]
+ assert_api_call(
+ self.client,
"psa-register-confirm",
{
- "flow": SocialAuthState.FLOW_REGISTER,
+ "flow": auth_state["flow"],
"verification_code": code.code,
"partial_token": partial_token,
},
{
"errors": [],
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
+ "flow": auth_state["flow"],
"redirect_url": None,
"partial_token": None,
"state": SocialAuthState.STATE_INVALID_EMAIL,
},
)
- yield run_step
-
-
-@pytest.fixture()
-def register_user_details(client):
- """Yield a function for this step"""
-
- def run_step(last_result):
- """Run the step"""
+ @rule(
+ target=RegisterExtraDetailsAuthStates,
+ auth_state=consumes(ConfirmationRedeemedAuthStates),
+ )
+ def register_details(self, auth_state):
+ """Complete the register confirmation details page"""
return assert_api_call(
- client,
+ self.client,
"psa-register-details",
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "partial_token": last_result["partial_token"],
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
"password": "password1",
"name": "Sally Smith",
"legal_address": {
@@ -439,85 +445,162 @@ def run_step(last_result):
},
},
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
+ "flow": auth_state["flow"],
"state": SocialAuthState.STATE_REGISTER_EXTRA_DETAILS,
},
- expect_authenticated=False,
)
- yield run_step
-
-
-@pytest.fixture()
-def register_user_extra_details(client):
- """Yield a function for this step"""
-
- def run_step(last_result):
- """Run the step"""
- return assert_api_call(
- client,
+ @rule(
+ target=RegisterExtraDetailsAuthStates,
+ auth_state=consumes(ConfirmationRedeemedAuthStates),
+ )
+ def register_details_export_success(self, auth_state):
+ """Complete the register confirmation details page with exports enabled"""
+ with export_check_response("100_success"):
+ result = assert_api_call(
+ self.client,
+ "psa-register-details",
+ {
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
+ "password": "password1",
+ "name": "Sally Smith",
+ "legal_address": {
+ "first_name": "Sally",
+ "last_name": "Smith",
+ "street_address": ["Main Street"],
+ "country": "US",
+ "state_or_territory": "US-CO",
+ "city": "Boulder",
+ "postal_code": "02183",
+ },
+ },
+ {
+ "flow": auth_state["flow"],
+ "state": SocialAuthState.STATE_REGISTER_EXTRA_DETAILS,
+ },
+ )
+ assert ExportsInquiryLog.objects.filter(user__email=self.email).exists()
+ assert (
+ ExportsInquiryLog.objects.get(user__email=self.email).computed_result
+ == RESULT_SUCCESS
+ )
+ assert len(mail.outbox) == 0
+
+ return result
+
+ @rule(auth_state=consumes(ConfirmationRedeemedAuthStates))
+ def register_details_export_reject(self, auth_state):
+ """Complete the register confirmation details page with exports enabled"""
+ with export_check_response("700_reject"):
+ assert_api_call(
+ self.client,
+ "psa-register-details",
+ {
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
+ "password": "password1",
+ "name": "Sally Smith",
+ "legal_address": {
+ "first_name": "Sally",
+ "last_name": "Smith",
+ "street_address": ["Main Street"],
+ "country": "US",
+ "state_or_territory": "US-CO",
+ "city": "Boulder",
+ "postal_code": "02183",
+ },
+ },
+ {
+ "flow": auth_state["flow"],
+ "partial_token": None,
+ "errors": [
+ "Unable to complete registration, please contact support"
+ ],
+ "state": SocialAuthState.STATE_USER_BLOCKED,
+ },
+ )
+ assert ExportsInquiryLog.objects.filter(user__email=self.email).exists()
+ assert (
+ ExportsInquiryLog.objects.get(user__email=self.email).computed_result
+ == RESULT_DENIED
+ )
+ assert len(mail.outbox) == 1
+
+ @rule(auth_state=consumes(ConfirmationRedeemedAuthStates))
+ def register_details_export_temporary_error(self, auth_state):
+ """Complete the register confirmation details page with exports raising a temporary error"""
+ with override_settings(**get_cybersource_test_settings()), patch(
+ "authentication.pipeline.compliance.api.verify_user_with_exports",
+ side_effect=Exception("register_details_export_temporary_error"),
+ ):
+ assert_api_call(
+ self.client,
+ "psa-register-details",
+ {
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
+ "password": "password1",
+ "name": "Sally Smith",
+ "legal_address": {
+ "first_name": "Sally",
+ "last_name": "Smith",
+ "street_address": ["Main Street"],
+ "country": "US",
+ "state_or_territory": "US-CO",
+ "city": "Boulder",
+ "postal_code": "02183",
+ },
+ },
+ {
+ "flow": auth_state["flow"],
+ "partial_token": None,
+ "errors": [
+ "Unable to register at this time, please try again later"
+ ],
+ "state": SocialAuthState.STATE_ERROR_TEMPORARY,
+ },
+ )
+ assert not ExportsInquiryLog.objects.filter(user__email=self.email).exists()
+ assert len(mail.outbox) == 0
+
+ @rule(auth_state=consumes(RegisterExtraDetailsAuthStates))
+ def register_user_extra_details(self, auth_state):
+ """Complete the user's extra details"""
+ assert_api_call(
+ Client(),
"psa-register-extra",
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "partial_token": last_result["partial_token"],
+ "flow": auth_state["flow"],
+ "partial_token": auth_state["partial_token"],
"gender": "f",
"birth_year": "2000",
"company": "MIT",
"job_title": "QA Manager",
},
{
- "flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
+ "flow": auth_state["flow"],
"state": SocialAuthState.STATE_SUCCESS,
"partial_token": None,
},
expect_authenticated=True,
)
- yield run_step
-
-@pytest.mark.betamax
-@pytest.mark.usefixture("mock_email_send")
-@pytest.mark.parametrize(
- "steps",
- [
- ["login_email_exists", "login_password_valid"],
- ["login_email_exists", "login_password_invalid"],
- ["login_email_exists", "login_password_user_inactive"],
- ["login_email_not_exists"],
- ["register_email_exists", "login_password_valid"],
- ["register_email_exists", "login_password_invalid"],
- [
- "register_email_not_exists",
- "redeem_confirmation_code",
- "register_user_details",
- "register_user_extra_details",
- ],
- [
- "register_email_not_exists_with_recaptcha",
- "redeem_confirmation_code",
- "register_user_details",
- "register_user_extra_details",
- ],
- [
- "register_email_not_exists",
- "redeem_confirmation_code",
- "redeem_confirmation_code_twice",
- ],
- ["register_email_not_exists_with_recaptcha_invalid"],
- ],
- ids=lambda arg: "->".join(arg) if isinstance(arg, list) else None,
+AuthStateMachine.TestCase.settings = hypothesis_settings(
+ max_examples=100,
+ stateful_step_count=10,
+ deadline=None,
+ verbosity=Verbosity.normal,
+ suppress_health_check=[HealthCheck.filter_too_much],
)
-def test_login_register_flows(request, steps):
- """Walk the steps and assert expected results"""
- last_result = None
- for fixture_name in steps:
- assert_step = request.getfixturevalue(fixture_name)
- last_result = assert_step(last_result)
+class AuthStateTestCase(HTestCase, AuthStateMachine.TestCase):
+ """TestCase for AuthStateMachine"""
+
+
+@pytest.mark.usefixtures("mock_email_send")
def test_new_register_no_session_partial(client):
"""
When a user registers for the first time and a verification email is sent, the partial
@@ -530,7 +613,6 @@ def test_new_register_no_session_partial(client):
{"flow": SocialAuthState.FLOW_REGISTER, "email": NEW_EMAIL},
{
"flow": SocialAuthState.FLOW_REGISTER,
- "provider": EmailAuth.name,
"partial_token": None,
"state": SocialAuthState.STATE_REGISTER_CONFIRM_SENT,
},
diff --git a/cms/factories.py b/cms/factories.py
index b2bbc833f..ee4fd610b 100644
--- a/cms/factories.py
+++ b/cms/factories.py
@@ -16,6 +16,7 @@
ResourcePage,
UserTestimonialsPage,
FacultyMembersPage,
+ SiteNotification,
)
from cms.blocks import (
LearningTechniqueBlock,
@@ -198,3 +199,12 @@ class FacultyMembersPageFactory(wagtail_factories.PageFactory):
class Meta:
model = FacultyMembersPage
+
+
+class SiteNotificationFactory(DjangoModelFactory):
+ """SiteNotification factory class"""
+
+ message = factory.fuzzy.FuzzyText(prefix="message ")
+
+ class Meta:
+ model = SiteNotification
diff --git a/cms/migrations/0017_sections_generalize.py b/cms/migrations/0017_sections_generalize.py
new file mode 100644
index 000000000..c31bb6ebc
--- /dev/null
+++ b/cms/migrations/0017_sections_generalize.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.1.7 on 2019-05-20 13:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [("cms", "0016_faculty_members_subpage")]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="forteamspage", options={"verbose_name": "Text-Image Section"}
+ ),
+ migrations.AlterModelOptions(
+ name="learningtechniquespage", options={"verbose_name": "Icon Grid"}
+ ),
+ migrations.AlterModelOptions(
+ name="usertestimonialspage",
+ options={"verbose_name": "Testimonials Section"},
+ ),
+ ]
diff --git a/cms/migrations/0018_sitenotification.py b/cms/migrations/0018_sitenotification.py
new file mode 100644
index 000000000..7e8d9ef6d
--- /dev/null
+++ b/cms/migrations/0018_sitenotification.py
@@ -0,0 +1,27 @@
+# Generated by Django 2.1.7 on 2019-05-22 06:15
+
+from django.db import migrations, models
+import wagtail.core.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [("cms", "0017_sections_generalize")]
+
+ operations = [
+ migrations.CreateModel(
+ name="SiteNotification",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("message", wagtail.core.fields.RichTextField(max_length=255)),
+ ],
+ )
+ ]
diff --git a/cms/models.py b/cms/models.py
index a1d524003..0c495f61b 100644
--- a/cms/models.py
+++ b/cms/models.py
@@ -18,6 +18,8 @@
from wagtail.core.blocks import RawHTMLBlock
from wagtail.images.models import Image
from wagtail.images.blocks import ImageChooserBlock
+from wagtail.snippets.models import register_snippet
+
from modelcluster.fields import ParentalKey
@@ -53,7 +55,8 @@ def can_create_at(cls, parent):
def save(self, *args, **kwargs):
# autogenerate a unique slug so we don't hit a ValidationError
- self.title = self.__class__._meta.verbose_name.title()
+ if not self.title:
+ self.title = self.__class__._meta.verbose_name.title()
self.slug = slugify("{}-{}".format(self.get_parent().id, self.title))
super().save(*args, **kwargs)
@@ -82,7 +85,7 @@ class UserTestimonialsPage(CourseProgramChildPage):
]
class Meta:
- verbose_name = "Testimonials Page"
+ verbose_name = "Testimonials Section"
class LearningOutcomesPage(CourseProgramChildPage):
@@ -128,7 +131,10 @@ class LearningTechniquesPage(CourseProgramChildPage):
help_text="Enter detail about how you'll learn.",
)
- content_panels = [StreamFieldPanel("technique_items")]
+ class Meta:
+ verbose_name = "Icon Grid"
+
+ content_panels = [FieldPanel("title"), StreamFieldPanel("technique_items")]
class ForTeamsPage(CourseProgramChildPage):
@@ -153,7 +159,12 @@ class ForTeamsPage(CourseProgramChildPage):
related_name="+",
help_text="Image size must be at least 750x505 pixels.",
)
+
+ class Meta:
+ verbose_name = "Text-Image Section"
+
content_panels = [
+ FieldPanel("title"),
FieldPanel("content"),
FieldPanel("action_title"),
FieldPanel("switch_layout"),
@@ -447,3 +458,17 @@ def save(self, *args, **kwargs):
self.slug = "%s-%d" % (original_slug, x)
super().save(*args, **kwargs)
+
+
+@register_snippet
+class SiteNotification(models.Model):
+ """ Snippet model for showing site notifications. """
+
+ message = RichTextField(
+ max_length=255, features=["bold", "italic", "link", "document-link"]
+ )
+
+ panels = [FieldPanel("message")]
+
+ def __str__(self):
+ return self.message
diff --git a/cms/models_test.py b/cms/models_test.py
index 84cdd7ed9..586a71def 100644
--- a/cms/models_test.py
+++ b/cms/models_test.py
@@ -3,7 +3,7 @@
import json
import pytest
-from cms.factories import ResourcePageFactory
+from cms.factories import ResourcePageFactory, SiteNotificationFactory
pytestmark = [pytest.mark.django_db]
@@ -44,3 +44,13 @@ def test_resource_page_unique_slug():
page = ResourcePageFactory(title="title of the page")
another_page = ResourcePageFactory(title="title of the page")
assert page.slug != another_page.slug
+
+
+def test_notification_snippet():
+ """
+ Verify that user can create site notification using cms.
+ """
+ message_text = "
hello this is a test notification
"
+ notification = SiteNotificationFactory(message=message_text)
+
+ assert str(notification) == message_text
diff --git a/compliance/__init__.py b/compliance/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/compliance/api.py b/compliance/api.py
new file mode 100644
index 000000000..fa67c4b75
--- /dev/null
+++ b/compliance/api.py
@@ -0,0 +1,197 @@
+"""Compliance API"""
+import logging
+
+from django.conf import settings
+from lxml import etree
+from nacl.encoding import Base64Encoder
+from nacl.public import PublicKey, SealedBox
+from zeep import Client
+from zeep.plugins import HistoryPlugin
+from zeep.wsse.username import UsernameToken
+
+from compliance.constants import (
+ REASON_CODE_SUCCESS,
+ EXPORTS_BLOCKED_REASON_CODES,
+ TEMPORARY_FAILURE_REASON_CODES,
+ RESULT_DENIED,
+ RESULT_SUCCESS,
+ RESULT_UNKNOWN,
+)
+from compliance.models import ExportsInquiryLog
+
+
+log = logging.getLogger()
+
+
+EXPORTS_REQUIRED_KEYS = [
+ "CYBERSOURCE_WSDL_URL",
+ "CYBERSOURCE_MERCHANT_ID",
+ "CYBERSOURCE_TRANSACTION_KEY",
+ "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY",
+]
+
+
+def is_exports_verification_enabled():
+ """Returns True if the exports verification is configured"""
+ return all(getattr(settings, key) for key in EXPORTS_REQUIRED_KEYS)
+
+
+def get_cybersource_client():
+ """
+ Configures and authenticates a CyberSource client
+
+ Returns:
+ (zeep.Client, zeep.plugins.HistoryPlugin):
+ a tuple of the configured client and the history plugin instance
+ """
+ wsse = UsernameToken(
+ settings.CYBERSOURCE_MERCHANT_ID, settings.CYBERSOURCE_TRANSACTION_KEY
+ )
+ history = HistoryPlugin()
+ client = Client(settings.CYBERSOURCE_WSDL_URL, wsse=wsse, plugins=[history])
+ return client, history
+
+
+def compute_result_from_codes(reason_code, info_code):
+ """
+ Determines the result from the reason and info codes
+
+ Args:
+ reason_code (int): the reasonCode returned from CyberSource
+ info_code (str): the infoCode returned from CyberSource
+
+ Returns:
+ str:
+ the computed result
+ """
+ # if there's either an explicit denial or any block list was triggered
+ # NOTE: reason_code can indicate a success but a block list still be triggered and indicated in info_code
+ if reason_code in EXPORTS_BLOCKED_REASON_CODES or info_code:
+ return RESULT_DENIED
+
+ # a success with no red flags whatsoever
+ if reason_code == REASON_CODE_SUCCESS:
+ return RESULT_SUCCESS
+
+ # failed to process an unknown reasonCode
+ log.error(
+ "Unable to verify exports controls, received unknown reasonCode: %s",
+ reason_code,
+ )
+ return RESULT_UNKNOWN
+
+
+def get_encryption_public_key():
+ """Returns the public key for encryption of export requests/responses"""
+ return PublicKey(
+ settings.CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY, encoder=Base64Encoder
+ )
+
+
+def log_exports_inquiry(user, response, last_sent, last_received):
+ """
+ Log a request/response for an export inquiry for a given user
+
+ Args:
+ user (users.models.User): the user that was checked for exports compliance
+ response (etree.Element): the root response node from the API call
+ last_sent (dict): the raw request sent for this call
+ last_received (dict): the raw response received for this call
+
+ Returns:
+ ExportsInquiryLog: the generate log record of the exports inquiry
+ """
+ # render lxml data structures into a string so we can encrypt it
+ xml_request = etree.tostring(last_sent["envelope"])
+ xml_response = etree.tostring(last_received["envelope"])
+
+ log.debug("Sent: %s", xml_request)
+ log.debug("Received: %s", xml_response)
+
+ # overall status code of the response
+ # NOTE: reason_code can indicate a success but a block list still be triggered and indicated in info_code
+ reason_code = int(response.reasonCode)
+
+ if reason_code in TEMPORARY_FAILURE_REASON_CODES:
+ # if it's a temporary failure in the CyberSource backend or
+ # the request itself, no point in recording this
+ log.error(
+ "Unable to verify exports controls, received reasonCode: %s", reason_code
+ )
+ return None
+
+ # if the data triggered a block list this will be truthy
+ info_code = response.exportReply.infoCode
+
+ box = SealedBox(get_encryption_public_key())
+ encrypted_request = box.encrypt(xml_request, encoder=Base64Encoder).decode("ascii")
+ encrypted_response = box.encrypt(xml_response, encoder=Base64Encoder).decode(
+ "ascii"
+ )
+
+ return ExportsInquiryLog.objects.create(
+ user=user,
+ computed_result=compute_result_from_codes(reason_code, info_code),
+ reason_code=reason_code,
+ info_code=info_code,
+ encrypted_request=encrypted_request,
+ encrypted_response=encrypted_response,
+ )
+
+
+def get_bill_to_address(user):
+ """
+ Create an address appropriate to pass to billTo on the CyberSource API
+
+ Args:
+ user (users.models.User): the user whose address to use
+
+ Returns:
+ dict:
+ User's legal_address in the appropriate data structure
+ """
+
+ legal_address = user.legal_address
+
+ # minimally require fields
+ billing_address = {
+ "firstName": legal_address.first_name,
+ "lastName": legal_address.last_name,
+ "email": user.email,
+ "street1": legal_address.street_address_1,
+ "street2": legal_address.street_address_2,
+ "street3": legal_address.street_address_3,
+ "street4": legal_address.street_address_4,
+ "city": legal_address.city,
+ "country": legal_address.country,
+ }
+
+ # these are required for certain countries, we presume here that data was validated before it was written
+ if legal_address.state_or_territory:
+ # State is in US-MA format and we want that send part
+ billing_address["state"] = legal_address.state_or_territory.split("-")[1]
+
+ if legal_address.postal_code:
+ billing_address["postalCode"] = legal_address.postal_code
+
+ return billing_address
+
+
+def verify_user_with_exports(user):
+ """Verify the user against the CyberSource exports service"""
+ client, history = get_cybersource_client()
+
+ payload = {
+ "merchantID": settings.CYBERSOURCE_MERCHANT_ID,
+ "merchantReferenceCode": user.id,
+ "billTo": get_bill_to_address(user),
+ "exportService": {
+ "run": "true", # NOTE: *must* be a string otherwise it will serialize incorrectly to "True"
+ "addressOperator": settings.CYBERSOURCE_EXPORT_SERVICE_ADDRESS_OPERATOR,
+ "addressWeight": settings.CYBERSOURCE_EXPORT_SERVICE_ADDRESS_WEIGHT,
+ "nameWeight": settings.CYBERSOURCE_EXPORT_SERVICE_NAME_WEIGHT,
+ },
+ }
+ response = client.service.runTransaction(**payload)
+
+ return log_exports_inquiry(user, response, history.last_sent, history.last_received)
diff --git a/compliance/api_test.py b/compliance/api_test.py
new file mode 100644
index 000000000..c0280623d
--- /dev/null
+++ b/compliance/api_test.py
@@ -0,0 +1,96 @@
+"""Tests for compliance api"""
+
+# pylint: disable=redefined-outer-name
+import pytest
+from lxml import etree
+from nacl.encoding import Base64Encoder
+from nacl.public import SealedBox
+
+from compliance import api
+from compliance.constants import (
+ RESULT_SUCCESS,
+ RESULT_DENIED,
+ RESULT_UNKNOWN,
+ TEMPORARY_FAILURE_REASON_CODES,
+)
+from compliance.models import ExportsInquiryLog
+
+
+@pytest.mark.usefixtures("cybersource_settings")
+def test_is_exports_verification_enabled():
+ """Test that is_exports_verification_enabled is true if all settings set"""
+ assert api.is_exports_verification_enabled() is True
+
+
+@pytest.mark.usefixtures("cybersource_settings")
+@pytest.mark.parametrize("key", api.EXPORTS_REQUIRED_KEYS)
+def test_is_exports_verification_disabled(settings, key):
+ """Test that is_exports_verification_enabled is false if a setting is missing"""
+ setattr(settings, key, None)
+ assert api.is_exports_verification_enabled() is False
+
+
+@pytest.mark.usefixtures("cybersource_settings")
+def test_log_exports_inquiry(mocker, cybersource_private_key, user):
+ """Test that log_exports_inquiry correctly stores the result"""
+ last_sent = {"envelope": etree.Element("sent")}
+ last_received = {"envelope": etree.Element("received")}
+ mock_response = mocker.Mock(
+ reasonCode="100", exportReply=mocker.Mock(infoCode="102")
+ )
+ log = api.log_exports_inquiry(user, mock_response, last_sent, last_received)
+
+ assert log.user == user
+ assert log.reason_code == 100
+ assert log.info_code == "102"
+
+ box = SealedBox(cybersource_private_key)
+
+ assert box.decrypt(log.encrypted_request, encoder=Base64Encoder) == b" "
+ assert box.decrypt(log.encrypted_response, encoder=Base64Encoder) == b" "
+
+
+@pytest.mark.parametrize(
+ "cybersource_mock_client_responses, expected_result",
+ [
+ ["700_reject", RESULT_DENIED],
+ ["100_success_match", RESULT_DENIED],
+ ["100_success", RESULT_SUCCESS],
+ ["978_unknown", RESULT_UNKNOWN],
+ ],
+ indirect=["cybersource_mock_client_responses"],
+)
+def test_verify_user_with_exports(
+ user, cybersource_mock_client_responses, expected_result
+): # pylint: disable=unused-argument
+ """Test that verify_user_with_exports handles"""
+ result = api.verify_user_with_exports(user)
+
+ assert result.computed_result == expected_result
+
+ assert ExportsInquiryLog.objects.filter(user=user).exists()
+
+
+@pytest.mark.usefixtures("cybersource_settings")
+@pytest.mark.parametrize("reason_code", TEMPORARY_FAILURE_REASON_CODES)
+def test_verify_user_with_exports_temporary_errors(mocker, user, reason_code):
+ """Verify no result is recorded if the nature of the error is temporary"""
+ mock_log = mocker.patch("compliance.api.log")
+ #
+ mock_client = mocker.Mock()
+ mock_client.service.runTransaction.return_value.reasonCode = str(reason_code)
+ # create a history with some dummy lxml objects
+ mock_history = mocker.Mock(
+ last_sent={"envelope": etree.Element("sent")},
+ last_received={"envelope": etree.Element("received")},
+ )
+ with mocker.patch(
+ "compliance.api.get_cybersource_client",
+ return_value=(mock_client, mock_history),
+ ):
+ assert api.verify_user_with_exports(user) is None
+ mock_log.error.assert_called_once_with(
+ "Unable to verify exports controls, received reasonCode: %s", reason_code
+ )
+
+ assert not ExportsInquiryLog.objects.filter(user=user).exists()
diff --git a/compliance/apps.py b/compliance/apps.py
new file mode 100644
index 000000000..51b9ac621
--- /dev/null
+++ b/compliance/apps.py
@@ -0,0 +1,8 @@
+"""Compliance app"""
+from django.apps import AppConfig
+
+
+class ComplianceConfig(AppConfig):
+ """Compliance AppConfig"""
+
+ name = "compliance"
diff --git a/compliance/constants.py b/compliance/constants.py
new file mode 100644
index 000000000..5d09c45f9
--- /dev/null
+++ b/compliance/constants.py
@@ -0,0 +1,60 @@
+"""Compliance constants"""
+
+# computed result states
+RESULT_SUCCESS = "SUCCESS"
+RESULT_MANUALLY_APPROVED = "MANUALLY_APPROVED"
+RESULT_DENIED = "DENIED"
+RESULT_TEMPORARY_FAILURE = "TEMPORARY_FAILURE"
+RESULT_UNKNOWN = "UNKNOWN"
+RESULT_CHOICES = (
+ RESULT_SUCCESS,
+ RESULT_MANUALLY_APPROVED,
+ RESULT_DENIED,
+ RESULT_TEMPORARY_FAILURE,
+ RESULT_UNKNOWN,
+)
+
+# REASON CODES
+
+# "SUCCESS"
+REASON_CODE_SUCCESS = 100
+
+# INPUT ERRORS
+REASON_CODE_MISSING_FIELDS = 101
+REASON_CODE_INVALID_FIELDS = 102
+
+# BACKEND FAILURES
+REASON_CODE_GENERAL_FAILURE = 150
+REASON_CODE_SERVER_TIMEOUT = 151
+REASON_CODE_SERVICE_TIMEOUT = 152
+
+# CONFIGURATION FAILURES
+REASON_CODE_MERCHANT_CONFIG_ERROR = 234
+
+# EXPORTS DENIED
+REASON_CODE_EMBARGO_CUSTOMER = 700
+REASON_CODE_EMBARGO_COUNTRY = 701
+REASON_CODE_EMBARGO_COUNTRY_EMAIL = 702
+REASON_CODE_EMBARGO_COUNTRY_BY_IP = 703
+
+# REASON CODE SETS
+INVALID_REQUEST_REASON_CODES = [REASON_CODE_MISSING_FIELDS, REASON_CODE_INVALID_FIELDS]
+CYBERSOURCE_BACKEND_FAILURE_REASON_CODES = [
+ REASON_CODE_GENERAL_FAILURE,
+ REASON_CODE_SERVER_TIMEOUT,
+ REASON_CODE_SERVICE_TIMEOUT,
+]
+CYBERSOURCE_CONFIG_ERROR_REASON_CODES = [REASON_CODE_MERCHANT_CONFIG_ERROR]
+
+TEMPORARY_FAILURE_REASON_CODES = (
+ INVALID_REQUEST_REASON_CODES
+ + CYBERSOURCE_BACKEND_FAILURE_REASON_CODES
+ + CYBERSOURCE_CONFIG_ERROR_REASON_CODES
+)
+
+EXPORTS_BLOCKED_REASON_CODES = [
+ REASON_CODE_EMBARGO_CUSTOMER,
+ REASON_CODE_EMBARGO_COUNTRY,
+ REASON_CODE_EMBARGO_COUNTRY_EMAIL,
+ REASON_CODE_EMBARGO_COUNTRY_BY_IP,
+]
diff --git a/compliance/factories.py b/compliance/factories.py
new file mode 100644
index 000000000..b3b34cc7c
--- /dev/null
+++ b/compliance/factories.py
@@ -0,0 +1,27 @@
+"""Factories for compliance app"""
+from factory import Faker, SubFactory, Trait
+from factory.django import DjangoModelFactory
+from factory.fuzzy import FuzzyChoice
+
+from compliance.constants import RESULT_DENIED, RESULT_SUCCESS, RESULT_CHOICES
+from compliance.models import ExportsInquiryLog
+
+
+class ExportsInquiryLogFactory(DjangoModelFactory):
+ """Factory for ExportsInquiryLog"""
+
+ user = SubFactory("users.factories.UserFactory")
+ computed_result = FuzzyChoice(RESULT_CHOICES)
+
+ reason_code = Faker("pyint")
+ info_code = Faker("numerify", text="###")
+
+ encrypted_request = Faker("pystr", max_chars=30)
+ encrypted_response = Faker("pystr", max_chars=30)
+
+ class Meta:
+ model = ExportsInquiryLog
+
+ class Params:
+ denied = Trait(computed_result=RESULT_DENIED)
+ success = Trait(computed_result=RESULT_SUCCESS)
diff --git a/compliance/migrations/0001_add_export_inquiry_log.py b/compliance/migrations/0001_add_export_inquiry_log.py
new file mode 100644
index 000000000..ff649de58
--- /dev/null
+++ b/compliance/migrations/0001_add_export_inquiry_log.py
@@ -0,0 +1,57 @@
+# Generated by Django 2.1.7 on 2019-05-10 13:10
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
+
+ operations = [
+ migrations.CreateModel(
+ name="ExportsInquiryLog",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_on", models.DateTimeField(auto_now_add=True)),
+ ("updated_on", models.DateTimeField(auto_now=True)),
+ (
+ "computed_result",
+ models.CharField(
+ choices=[
+ ("SUCCESS", "SUCCESS"),
+ ("MANUALLY_APPROVED", "MANUALLY_APPROVED"),
+ ("DENIED", "DENIED"),
+ ("TEMPORARY_FAILURE", "TEMPORARY_FAILURE"),
+ ("UNKNOWN", "UNKNOWN"),
+ ],
+ max_length=30,
+ ),
+ ),
+ ("reason_code", models.IntegerField()),
+ ("info_code", models.CharField(max_length=255, null=True)),
+ ("encrypted_request", models.TextField()),
+ ("encrypted_response", models.TextField()),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="exports_inquiries",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={"abstract": False},
+ )
+ ]
diff --git a/compliance/migrations/__init__.py b/compliance/migrations/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/compliance/models.py b/compliance/models.py
new file mode 100644
index 000000000..3fda83447
--- /dev/null
+++ b/compliance/models.py
@@ -0,0 +1,49 @@
+"""Compliance app models"""
+from django.conf import settings
+from django.db import models
+
+from compliance.constants import (
+ RESULT_CHOICES,
+ RESULT_SUCCESS,
+ RESULT_DENIED,
+ RESULT_UNKNOWN,
+ RESULT_MANUALLY_APPROVED,
+)
+from mitxpro.models import TimestampedModel
+
+
+class ExportsInquiryLog(TimestampedModel):
+ """
+ Model to track exports exports_inquiries
+ """
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="exports_inquiries",
+ )
+
+ computed_result = models.CharField(
+ max_length=30, choices=zip(RESULT_CHOICES, RESULT_CHOICES)
+ )
+
+ reason_code = models.IntegerField()
+ info_code = models.CharField(max_length=255, null=True)
+
+ encrypted_request = models.TextField()
+ encrypted_response = models.TextField()
+
+ @property
+ def is_denied(self):
+ """Returns true if the export result was denied"""
+ return self.computed_result == RESULT_DENIED
+
+ @property
+ def is_success(self):
+ """Returns true if the export result was a success"""
+ return self.computed_result in (RESULT_SUCCESS, RESULT_MANUALLY_APPROVED)
+
+ @property
+ def is_unknown(self):
+ """Returns true if the export result was unknown"""
+ return self.computed_result == RESULT_UNKNOWN
diff --git a/compliance/test_data/cybersource/100_success.xml b/compliance/test_data/cybersource/100_success.xml
new file mode 100644
index 000000000..51632fb4a
--- /dev/null
+++ b/compliance/test_data/cybersource/100_success.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ 2019-05-09T18:21:31.312Z
+
+
+
+
+
+ example
+ 123
+ SUCCESS
+ 100
+ TOKEN
+
+ 100
+
+
+
+
diff --git a/compliance/test_data/cybersource/100_success_match.xml b/compliance/test_data/cybersource/100_success_match.xml
new file mode 100644
index 000000000..5fb49808d
--- /dev/null
+++ b/compliance/test_data/cybersource/100_success_match.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ 2019-05-09T18:21:31.312Z
+
+
+
+
+
+ example
+ 123
+ SUCCESS
+ 100
+ TOKEN
+
+ Office of Foreign Assets Control
+ Mohammed QASIM
+ aka, QASIM, Muhammad
+ ,Waish,Spin Boldak,null,
+ Bypass Road,Chaman,,null,
+ Karez Qaran,Musa Qal'ah,Helmand Province,null,
+ Qalaye Haji Ali Akbar Dalbandin Post Office,,,null,
+ Room 33, 5th Floor Sarafi Market,Kandahar,,null,
+ Safaar Bazaar,Garmsir,,null,
+ SDGT
+
+
+ 100
+ MATCH-DPC
+
+
+
+
diff --git a/compliance/test_data/cybersource/151_timeout.xml b/compliance/test_data/cybersource/151_timeout.xml
new file mode 100644
index 000000000..8b7f5b5fa
--- /dev/null
+++ b/compliance/test_data/cybersource/151_timeout.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ 2019-05-09T18:21:31.312Z
+
+
+
+
+
+ example
+ 123
+ ERROR
+ 151
+ TOKEN
+
+ 151
+
+
+
+
diff --git a/compliance/test_data/cybersource/700_reject.xml b/compliance/test_data/cybersource/700_reject.xml
new file mode 100644
index 000000000..475d8e7e9
--- /dev/null
+++ b/compliance/test_data/cybersource/700_reject.xml
@@ -0,0 +1,34 @@
+
+
+
+
+ 2019-05-09T18:21:31.312Z
+
+
+
+
+
+ example
+ 123
+ REJECT
+ 700
+ TOKEN
+
+ Office of Foreign Assets Control
+ Mohammed QASIM
+ aka, QASIM, Muhammad
+ ,Waish,Spin Boldak,null,
+ Bypass Road,Chaman,,null,
+ Karez Qaran,Musa Qal'ah,Helmand Province,null,
+ Qalaye Haji Ali Akbar Dalbandin Post Office,,,null,
+ Room 33, 5th Floor Sarafi Market,Kandahar,,null,
+ Safaar Bazaar,Garmsir,,null,
+ SDGT
+
+
+ 700
+ MATCH-DPC
+
+
+
+
diff --git a/compliance/test_data/cybersource/978_unknown.xml b/compliance/test_data/cybersource/978_unknown.xml
new file mode 100644
index 000000000..1c6fb1f2d
--- /dev/null
+++ b/compliance/test_data/cybersource/978_unknown.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ 2019-05-09T18:21:31.312Z
+
+
+
+
+
+ example
+ 123
+ SUCCESS
+ 978
+ TOKEN
+
+ 978
+
+
+
+
diff --git a/compliance/test_data/cybersource/CyberSourceTransaction_1.154.wsdl b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.wsdl
new file mode 100644
index 000000000..7c588586f
--- /dev/null
+++ b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.wsdl
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FOR PYTEST ONLY
+
+
+
+
+
diff --git a/compliance/test_data/cybersource/CyberSourceTransaction_1.154.xsd b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.xsd
new file mode 100644
index 000000000..89ed4b2be
--- /dev/null
+++ b/compliance/test_data/cybersource/CyberSourceTransaction_1.154.xsd
@@ -0,0 +1,4840 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compliance/test_utils.py b/compliance/test_utils.py
new file mode 100644
index 000000000..dcb85fa91
--- /dev/null
+++ b/compliance/test_utils.py
@@ -0,0 +1,61 @@
+"""Testing utils around CyberSource"""
+from nacl.public import PrivateKey
+from nacl.encoding import Base64Encoder
+from rest_framework import status
+
+SERVICE_VERSION = "1.154"
+
+DATA_DIR = "compliance/test_data/cybersource"
+
+
+def get_cybersource_test_settings(private_key=None):
+ """
+ Generates a valid set of settings for CyberSource
+ """
+ if private_key is None:
+ private_key = PrivateKey.generate()
+
+ return {
+ "CYBERSOURCE_WSDL_URL": (
+ f"http://localhost/service/CyberSourceTransaction_{SERVICE_VERSION}.wsdl"
+ ),
+ "CYBERSOURCE_MERCHANT_ID": "merchant_id",
+ "CYBERSOURCE_TRANSACTION_KEY": "transaction_key",
+ "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY": Base64Encoder.encode(
+ bytes(private_key.public_key)
+ ),
+ }
+
+
+def mock_cybersource_wsdl(mocked_responses, settings, service_version=SERVICE_VERSION):
+ """
+ Mocks the responses to achieve a functional WSDL
+ """
+ # in order for zeep to load the wsdl, it will load the wsdl and the accompanying xsd definitions
+ with open(f"{DATA_DIR}/CyberSourceTransaction_{service_version}.wsdl", "r") as wsdl:
+ mocked_responses.add(
+ mocked_responses.GET,
+ settings.CYBERSOURCE_WSDL_URL,
+ body=wsdl.read(),
+ status=status.HTTP_200_OK,
+ )
+ with open(f"{DATA_DIR}/CyberSourceTransaction_{SERVICE_VERSION}.xsd", "r") as xsd:
+ mocked_responses.add(
+ mocked_responses.GET,
+ f"http://localhost/service/CyberSourceTransaction_{service_version}.xsd",
+ body=xsd.read(),
+ status=status.HTTP_200_OK,
+ )
+
+
+def mock_cybersource_wsdl_operation(mocked_responses, response_name):
+ """
+ Mock the response for an operation
+ """
+ with open(f"{DATA_DIR}/{response_name}.xml") as operation_response:
+ mocked_responses.add(
+ mocked_responses.POST,
+ "http://localhost/service",
+ body=operation_response.read(),
+ status=status.HTTP_200_OK,
+ )
diff --git a/conftest.py b/conftest.py
index 7429e8559..b5f7bfb66 100644
--- a/conftest.py
+++ b/conftest.py
@@ -8,6 +8,7 @@
from fixtures.common import *
from fixtures.autouse import *
+from fixtures.cybersource import *
TEST_MEDIA_ROOT = "/var/media/test_media_root"
diff --git a/courses/models.py b/courses/models.py
index eb64ddb1a..8d3d18489 100644
--- a/courses/models.py
+++ b/courses/models.py
@@ -6,6 +6,7 @@
from django.db import models
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericRelation
+from django.urls import reverse
from cms.models import (
LearningOutcomesPage,
@@ -240,6 +241,20 @@ def course_lineup(self):
"""Gets the CoursesInProgram subpage if associated with this program"""
return self._get_child_page_of_type(CoursesInProgramPage)
+ @property
+ def url(self):
+ """
+ Gets the URL for this resource
+ """
+ return NotImplementedError()
+
+ @property
+ def type_name(self):
+ """
+ Gets the descriptive word for the type of this resource
+ """
+ return "program"
+
def __str__(self):
return self.title
@@ -314,6 +329,20 @@ def unexpired_runs(self):
)
)
+ @property
+ def url(self):
+ """
+ Gets the URL for this resource
+ """
+ return reverse("course-detail", kwargs={"pk": self.pk})
+
+ @property
+ def type_name(self):
+ """
+ Gets the descriptive word for the type of this resource
+ """
+ return "course"
+
class Meta:
ordering = ("program", "title")
diff --git a/courses/templates/course_detail.html b/courses/templates/course_detail.html
index 924be1aec..0399a83b1 100644
--- a/courses/templates/course_detail.html
+++ b/courses/templates/course_detail.html
@@ -31,13 +31,15 @@
{% include "partials/subnav.html" with product=course %}
{% if course.outcomes %} {% include "partials/learning-outcomes.html" %} {% endif %}
{% if course.who_should_enroll %} {% include "partials/target-audience.html" %} {% endif %}
- {% if course.techniques %} {% include "partials/learning-techniques.html" %} {% endif %}
- {% if course.testimonials %} {% include "partials/testimonial-carousel.html" with testimonials=course.testimonials %} {% endif %}
+ {% if course.techniques %} {% include "partials/learning-techniques.html" with page=course.techniques %} {% endif %}
+ {% if course.testimonials %} {% include "partials/testimonial-carousel.html" with page=course.testimonials %} {% endif %}
{% if course.faculty %} {% include "partials/faculty-carousel.html" %} {% endif %}
{% if course.program and course.program.course_lineup %}
- {% include "partials/course-carousel.html" with program=course.program %}
+ {% with courseware_objects=course.program.courses.all page=course.program.course_lineup %}
+ {% include "partials/course-carousel.html" with button_title="View Full Program" button_url="#" %}
+ {% endwith %}
{% endif %}
- {% if course.for_teams %} {% include "partials/for-teams.html" %} {% endif %}
+ {% if course.for_teams %} {% include "partials/for-teams.html" with page=course.for_teams %} {% endif %}
{% if course.faqs %} {% include "partials/faqs.html" %} {% endif %}
{% endblock %}
diff --git a/courses/templates/partials/course-carousel.html b/courses/templates/partials/course-carousel.html
index cb8173133..fbffccda7 100644
--- a/courses/templates/partials/course-carousel.html
+++ b/courses/templates/partials/course-carousel.html
@@ -2,26 +2,28 @@
-
{{ program.course_lineup.heading }}
- {{ program.course_lineup.body|safe }}
+ {{ page.heading }}
+ {{ page.body|safe }}
- {% for child_course in program.courses.all %}
+ {% for courseware_object in courseware_objects %}
- {% if child_course.thumbnail_image %}
- {% image child_course.thumbnail_image fill-480x275 as course_image %}
-
+ {% if courseware_object.thumbnail_image %}
+ {% image courseware_object.thumbnail_image fill-480x275 as course_image %}
+
{% else %}
-
+
{% endif %}
-
{{ child_course.display_title }}
-
{{ child_course.subhead }}
-
view course
+
{{ courseware_object.display_title }}
+
{{ courseware_object.subhead }}
+
view {{ courseware_object.type_name }}
{% endfor %}
-
View Full Program
+ {% if button_title and button_url %}
+
{{ button_title }}
+ {% endif %}
\ No newline at end of file
diff --git a/courses/templates/partials/for-teams.html b/courses/templates/partials/for-teams.html
index 7d8a31a25..af14d0aa1 100644
--- a/courses/templates/partials/for-teams.html
+++ b/courses/templates/partials/for-teams.html
@@ -2,15 +2,15 @@
-
-
For Teams
- {{ course.for_teams.content|safe }}
-
{{ course.for_teams.action_title }}
+
+
{{ page.title }}
+ {{ page.content|safe }}
+
{{ page.action_title }}
-
- {% if course.for_teams.image %}
- {% image course.for_teams.image fill-750x505 %}
+
+ {% if page.image %}
+ {% image page.image fill-750x505 %}
{% else %}
{% endif%}
diff --git a/courses/templates/partials/learning-techniques.html b/courses/templates/partials/learning-techniques.html
index 285c6cc89..d8a9c6468 100644
--- a/courses/templates/partials/learning-techniques.html
+++ b/courses/templates/partials/learning-techniques.html
@@ -1,8 +1,8 @@
{% load wagtailimages_tags %}
-
How You’ll Learn
+
{{ page.title }}
- {% for technique in course.techniques.technique_items %}
+ {% for technique in page.technique_items %}
{% image technique.value.image height-110 format-png %}
diff --git a/courses/templates/partials/testimonial-carousel.html b/courses/templates/partials/testimonial-carousel.html
index 923502f67..1d7c0c9aa 100644
--- a/courses/templates/partials/testimonial-carousel.html
+++ b/courses/templates/partials/testimonial-carousel.html
@@ -2,11 +2,11 @@
-
{{ testimonials.heading }}
- {{ testimonials.subhead }}
+ {{ page.heading }}
+ {{ page.subhead }}
- {% for testimonial in testimonials.items %}
+ {% for testimonial in page.items %}
{% if testimonial.value.image %}
@@ -20,7 +20,7 @@
{{ testimonial.value.name }}, {{ testimonial.value.title }}
{% endfor %}
- {% for testimonial in testimonials.items %}
+ {% for testimonial in page.items %}
diff --git a/ecommerce/hubspot_api.py b/ecommerce/hubspot_api.py
new file mode 100644
index 000000000..41a223d13
--- /dev/null
+++ b/ecommerce/hubspot_api.py
@@ -0,0 +1,135 @@
+"""
+Hubspot Ecommerce Bridge API sync utilities
+
+https://developers.hubspot.com/docs/methods/ecomm-bridge/ecomm-bridge-overview
+"""
+import time
+from urllib.parse import urljoin, urlencode
+import requests
+from django.conf import settings
+
+
+HUBSPOT_API_BASE_URL = "https://api.hubapi.com"
+
+
+def send_hubspot_request(
+ endpoint, api_url, method, body=None, query_params=None, **kwargs
+):
+ """
+ Send a request to Hubspot using the given params, body and api key specified in settings
+
+ Args:
+ endpoint (String): Specific endpoint to hit. Can be the empty string
+ api_url (String): The url path to append endpoint to
+ method (String): GET, POST, or PUT
+ body (serializable data): Data to be JSON serialized and sent with a PUT or POST request
+ query_params (Dict): Params to be added to the query string
+ kwargs: keyword arguments to add to the request method
+ Returns:
+ HTML response to the constructed url
+ """
+
+ base_url = urljoin(f"{HUBSPOT_API_BASE_URL}/", api_url)
+ if endpoint:
+ base_url = urljoin(f"{base_url}/", endpoint)
+ if query_params is None:
+ query_params = {}
+ if "hapikey" not in query_params:
+ query_params["hapikey"] = settings.HUBSPOT_API_KEY
+ params = urlencode(query_params)
+ url = f"{base_url}?{params}"
+ if method == "GET":
+ return requests.get(url=url, **kwargs)
+ if method == "PUT":
+ return requests.put(url=url, json=body, **kwargs)
+ if method == "POST":
+ return requests.post(url=url, json=body, **kwargs)
+
+
+def make_sync_message(object_id, properties):
+ """
+ Create data for sync message
+ Args:
+ object_id (ObjectID): Internal ID to match with Hubspot object
+ properties (dict): dict of properties to be synced
+ Returns:
+ dict to be serialized as body in sync-message
+ """
+ for key in properties.keys():
+ if properties[key] is None:
+ properties[key] = ""
+ return {
+ "integratorObjectId": str(object_id),
+ "action": "UPSERT",
+ "changeOccurredTimestamp": int(time.time() * 1000),
+ "propertyNameToValues": dict(properties),
+ }
+
+
+def get_sync_errors(limit=200, offset=0):
+ """
+ Get errors that have occurred during sync
+ Args:
+ limit (Int): The number of errors to be returned
+ offset (Int): The index of the first error to be returned
+ Returns:
+ HTML response including error data
+ """
+ response = send_hubspot_request(
+ "sync-errors",
+ "/extensions/ecomm/v1",
+ "GET",
+ query_params={"limit": limit, "offset": offset},
+ )
+ response.raise_for_status()
+ return response
+
+
+def make_contact_sync_message(user_id):
+ """
+ Create the body of a sync message for a contact. This will flatten the contained LegalAddress and Profile
+ serialized data into one larger serializable dict
+ Args:
+ user_id (ObjectID): ID of user to sync contact with
+ Returns:
+ dict containing serializable sync-message data
+ """
+ from users.models import User
+ from users.serializers import UserSerializer
+
+ user = User.objects.get(id=user_id)
+ properties = UserSerializer(user).data
+ properties.update(properties.pop("legal_address") or {})
+ properties.update(properties.pop("profile") or {})
+ properties["street_address"] = "\n".join(properties.pop("street_address"))
+ return make_sync_message(user.id, properties)
+
+
+def make_deal_sync_message(order_id):
+ """
+ Create the body of a sync message for a deal.
+ Args:
+ Returns:
+ dict containing serializable sync-message data
+ """
+ return make_sync_message(order_id, {})
+
+
+def make_line_item_sync_message(line_id):
+ """
+ Create the body of a sync message for a line item.
+ Args:
+ Returns:
+ dict containing serializable sync-message data
+ """
+ return make_sync_message(line_id, {})
+
+
+def make_product_sync_message(product_id):
+ """
+ Create the body of a sync message for a product.
+ Args:
+ Returns:
+ dict containing serializable sync-message data
+ """
+ return make_sync_message(product_id, {})
diff --git a/ecommerce/hubspot_api_test.py b/ecommerce/hubspot_api_test.py
new file mode 100644
index 000000000..afa736094
--- /dev/null
+++ b/ecommerce/hubspot_api_test.py
@@ -0,0 +1,102 @@
+"""
+Hubspot API tests
+"""
+# pylint: disable=redefined-outer-name
+from urllib.parse import urlencode
+
+import pytest
+from faker import Faker
+from django.conf import settings
+
+from ecommerce.hubspot_api import (
+ send_hubspot_request,
+ HUBSPOT_API_BASE_URL,
+ make_sync_message,
+ make_contact_sync_message,
+)
+from users.serializers import UserSerializer
+
+fake = Faker()
+
+
+@pytest.mark.parametrize("request_method", ["GET", "PUT", "POST"])
+@pytest.mark.parametrize(
+ "endpoint,api_url,expected_url",
+ [
+ [
+ "sync-errors",
+ "/extensions/ecomm/v1",
+ f"{HUBSPOT_API_BASE_URL}/extensions/ecomm/v1/sync-errors",
+ ],
+ [
+ "",
+ "/extensions/ecomm/v1/installs",
+ f"{HUBSPOT_API_BASE_URL}/extensions/ecomm/v1/installs",
+ ],
+ [
+ "CONTACT",
+ "/extensions/ecomm/v1/sync-messages",
+ f"{HUBSPOT_API_BASE_URL}/extensions/ecomm/v1/sync-messages/CONTACT",
+ ],
+ ],
+)
+def test_send_hubspot_request(mocker, request_method, endpoint, api_url, expected_url):
+ """Test sending hubspot request with method = GET"""
+ value = fake.pyint()
+ query_params = {"param": value}
+
+ # Include hapikey when generating url to match request call against
+ full_query_params = {"param": value, "hapikey": settings.HUBSPOT_API_KEY}
+ mock_request = mocker.patch(
+ f"ecommerce.hubspot_api.requests.{request_method.lower()}"
+ )
+ url_params = urlencode(full_query_params)
+ url = f"{expected_url}?{url_params}"
+ if request_method == "GET":
+ send_hubspot_request(
+ endpoint, api_url, request_method, query_params=query_params
+ )
+ mock_request.assert_called_once_with(url=url)
+ else:
+ body = fake.pydict()
+ send_hubspot_request(
+ endpoint, api_url, request_method, query_params=query_params, body=body
+ )
+ mock_request.assert_called_once_with(url=url, json=body)
+
+
+def test_make_sync_message():
+ """Test make_sync_message produces a properly formatted sync-message"""
+ object_id = fake.pyint()
+ value = fake.word()
+ properties = {"prop": value, "blank": None}
+ sync_message = make_sync_message(object_id, properties)
+ time = sync_message["changeOccurredTimestamp"]
+ assert sync_message == (
+ {
+ "integratorObjectId": str(object_id),
+ "action": "UPSERT",
+ "changeOccurredTimestamp": time,
+ "propertyNameToValues": {"prop": value, "blank": ""},
+ }
+ )
+
+
+def test_make_contact_sync_message(user):
+ """Test make_contact_sync_message serializes a user and returns a properly formatted sync message"""
+ contact_sync_message = make_contact_sync_message(user.id)
+
+ serialized_user = UserSerializer(user).data
+ serialized_user.update(serialized_user.pop("legal_address") or {})
+ serialized_user.update(serialized_user.pop("profile") or {})
+ serialized_user["street_address"] = "\n".join(serialized_user.pop("street_address"))
+
+ time = contact_sync_message["changeOccurredTimestamp"]
+ assert contact_sync_message == (
+ {
+ "integratorObjectId": str(user.id),
+ "action": "UPSERT",
+ "changeOccurredTimestamp": time,
+ "propertyNameToValues": serialized_user,
+ }
+ )
diff --git a/ecommerce/management/commands/configure_hubspot_bridge.py b/ecommerce/management/commands/configure_hubspot_bridge.py
new file mode 100644
index 000000000..00464f687
--- /dev/null
+++ b/ecommerce/management/commands/configure_hubspot_bridge.py
@@ -0,0 +1,245 @@
+"""
+Management command to configure the Hubspot ecommerce bridge which handles syncing Contacts, Deals, Products,
+and Line Items
+"""
+import json
+from django.core.management import BaseCommand
+
+from ecommerce.hubspot_api import send_hubspot_request
+
+# Hubspot ecommerce settings define which hubspot properties are mapped with which
+# local properties when objects are synced.
+# See https://developers.hubspot.com/docs/methods/ecomm-bridge/ecomm-bridge-overview for more details
+HUBSPOT_ECOMMERCE_SETTINGS = {
+ "enabled": True,
+ "productSyncSettings": {
+ "properties": [
+ {
+ "propertyName": "created_on",
+ "targetHubspotProperty": "createdate",
+ "dataType": "DATETIME",
+ },
+ {
+ "propertyName": "title",
+ "targetHubspotProperty": "name",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "price",
+ "targetHubspotProperty": "price",
+ "dataType": "NUMBER",
+ },
+ {
+ "propertyName": "description",
+ "targetHubspotProperty": "description",
+ "dataType": "STRING",
+ },
+ ]
+ },
+ "contactSyncSettings": {
+ "properties": [
+ {
+ "propertyName": "email",
+ "targetHubspotProperty": "email",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "name",
+ "targetHubspotProperty": "name",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "first_name",
+ "targetHubspotProperty": "firstname",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "last_name",
+ "targetHubspotProperty": "lastname",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "street_address",
+ "targetHubspotProperty": "address",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "city",
+ "targetHubspotProperty": "city",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "country",
+ "targetHubspotProperty": "country",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "state_or_territory",
+ "targetHubspotProperty": "state",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "postal_code",
+ "targetHubspotProperty": "zip",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "birth_year",
+ "targetHubspotProperty": "birth_year",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "gender",
+ "targetHubspotProperty": "gender",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "company",
+ "targetHubspotProperty": "company",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "company_size",
+ "targetHubspotProperty": "company_size",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "industry",
+ "targetHubspotProperty": "industry",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "job_title",
+ "targetHubspotProperty": "jobtitle",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "job_function",
+ "targetHubspotProperty": "job_function",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "years_experience",
+ "targetHubspotProperty": "years_experience",
+ "dataType": "STRING",
+ },
+ {
+ "propertyName": "leadership_level",
+ "targetHubspotProperty": "leadership_level",
+ "dataType": "STRING",
+ },
+ ]
+ },
+ "dealSyncSettings": {
+ "properties": [
+ {
+ "propertyName": "status",
+ "targetHubspotProperty": "dealstage",
+ "dataType": "STRING",
+ }
+ ]
+ },
+ "lineItemSyncSettings": {"properties": []},
+}
+
+HUBSPOT_INSTALL_PATH = "/extensions/ecomm/v1/installs"
+HUBSPOT_SETTINGS_PATH = "/extensions/ecomm/v1/settings"
+
+
+def install_hubspot_ecommerce_bridge():
+ """Install the Hubspot ecommerce bridge for the api key specified in settings"""
+ response = send_hubspot_request("", HUBSPOT_INSTALL_PATH, "POST")
+ response.raise_for_status()
+ return response
+
+
+def uninstall_hubspot_ecommerce_bridge():
+ """Install the Hubspot ecommerce bridge for the api key specified in settings"""
+ response = send_hubspot_request("uninstall", HUBSPOT_INSTALL_PATH, "POST")
+ response.raise_for_status()
+ return response
+
+
+def get_hubspot_installation_status():
+ """Get the Hubspot ecommerce bridge installation status for the api key specified in settings"""
+ response = send_hubspot_request("status", HUBSPOT_INSTALL_PATH, "GET")
+ response.raise_for_status()
+ return response
+
+
+def configure_hubspot_settings():
+ """Configure the current Hubspot ecommerce bridge settings for the api key specified in settings"""
+ response = send_hubspot_request(
+ "", HUBSPOT_SETTINGS_PATH, "PUT", body=HUBSPOT_ECOMMERCE_SETTINGS
+ )
+ response.raise_for_status()
+ return response
+
+
+def get_hubspot_settings():
+ """Get the current Hubspot ecommerce bridge settings for the api key specified in settings"""
+ response = send_hubspot_request("", HUBSPOT_SETTINGS_PATH, "GET")
+ response.raise_for_status()
+ return response
+
+
+class Command(BaseCommand):
+ """
+ Command to configure the Hubspot ecommerce bridge which will handle syncing Hubspot Products, Deals, Line Items,
+ and Contacts with the MITxPro Products, Orders, and Users
+ """
+
+ help = (
+ "Install the Hubspot Ecommerce Bridge if it is not already installed and configure the settings based on "
+ "the given file. Make sure a HUBSPOT_API_KEY is set in settings and HUBSPOT_ECOMMERCE_SETTINGS are "
+ "configured in ecommerce/management/commands/configure_hubspot_bridge.py"
+ )
+
+ def add_arguments(self, parser):
+ """
+ Definition of arguments this command accepts
+ """
+ parser.add_argument(
+ "--uninstall", action="store_true", help="Uninstall the Ecommerce Bridge"
+ )
+
+ parser.add_argument(
+ "--status",
+ action="store_true",
+ help="Get the current status of the Ecommerce Bridge installation",
+ )
+
+ def handle(self, *args, **options):
+ print(
+ f"Checking Hubspot Ecommerce Bridge installation for given Hubspot API Key..."
+ )
+ installation_status = json.loads(get_hubspot_installation_status().text)
+ if options["status"]:
+ print(f"Install completed: {installation_status['installCompleted']}")
+ print(
+ f"Ecommerce Settings enabled: {installation_status['ecommSettingsEnabled']}"
+ )
+ if options["uninstall"]:
+ if installation_status["installCompleted"]:
+ print("Uninstalling Ecommerce Bridge...")
+ uninstall_hubspot_ecommerce_bridge()
+ print("Uninstall successful")
+ return
+ else:
+ print("Ecommerce Bridge is not installed")
+ return
+ if not installation_status["installCompleted"]:
+ print(f"Installation not found. Installing now...")
+ install_hubspot_ecommerce_bridge()
+ print("Install successful")
+ else:
+ print(f"Installation found")
+ if options["status"]:
+ print("Getting settings...")
+ ecommerce_bridge_settings = json.loads(get_hubspot_settings().text)
+ print("Found settings:")
+ print(ecommerce_bridge_settings)
+ else:
+ print(f"Configuring settings...")
+ configure_hubspot_settings()
+ print(f"Settings configured")
diff --git a/ecommerce/tasks.py b/ecommerce/tasks.py
new file mode 100644
index 000000000..9e784af28
--- /dev/null
+++ b/ecommerce/tasks.py
@@ -0,0 +1,46 @@
+"""
+Ecommerce tasks
+"""
+from ecommerce.hubspot_api import (
+ send_hubspot_request,
+ make_contact_sync_message,
+ make_product_sync_message,
+ make_deal_sync_message,
+ make_line_item_sync_message,
+)
+from mitxpro.celery import app
+
+
+HUBSPOT_SYNC_URL = "/extensions/ecomm/v1/sync-messages"
+
+
+@app.task
+def sync_contact_with_hubspot(user_id):
+ """Send a sync-message to sync a user with a hubspot contact"""
+ body = [make_contact_sync_message(user_id)]
+ response = send_hubspot_request("CONTACT", HUBSPOT_SYNC_URL, "PUT", body=body)
+ response.raise_for_status()
+
+
+@app.task
+def sync_product_with_hubspot(product_id):
+ """Send a sync-message to sync a product with a hubspot product"""
+ body = [make_product_sync_message(product_id)]
+ response = send_hubspot_request("PRODUCT", HUBSPOT_SYNC_URL, "PUT", body=body)
+ response.raise_for_status()
+
+
+@app.task
+def sync_deal_with_hubspot(order_id):
+ """Send a sync-message to sync an order with a hubspot deal"""
+ body = [make_deal_sync_message(order_id)]
+ response = send_hubspot_request("DEAL", HUBSPOT_SYNC_URL, "PUT", body=body)
+ response.raise_for_status()
+
+
+@app.task
+def sync_line_item_with_hubspot(line_id):
+ """Send a sync-message to sync a line with a hubspot line item"""
+ body = [make_line_item_sync_message(line_id)]
+ response = send_hubspot_request("LINE_ITEM", HUBSPOT_SYNC_URL, "PUT", body=body)
+ response.raise_for_status()
diff --git a/ecommerce/tasks_test.py b/ecommerce/tasks_test.py
new file mode 100644
index 000000000..ce45c9126
--- /dev/null
+++ b/ecommerce/tasks_test.py
@@ -0,0 +1,81 @@
+"""
+Tests for ecommerce tasks
+"""
+# pylint: disable=redefined-outer-name
+from unittest.mock import ANY
+
+import pytest
+
+from faker import Faker
+
+from ecommerce.factories import ProductFactory, OrderFactory, LineFactory
+from ecommerce.hubspot_api import (
+ make_contact_sync_message,
+ make_product_sync_message,
+ make_deal_sync_message,
+ make_line_item_sync_message,
+)
+from ecommerce.tasks import (
+ sync_contact_with_hubspot,
+ HUBSPOT_SYNC_URL,
+ sync_product_with_hubspot,
+ sync_deal_with_hubspot,
+ sync_line_item_with_hubspot,
+)
+from users.factories import UserFactory
+
+fake = Faker()
+
+
+@pytest.fixture
+def mock_hubspot_request(mocker):
+ """Mock the send hubspot request method"""
+ yield mocker.patch("ecommerce.tasks.send_hubspot_request")
+
+
+@pytest.mark.django_db
+def test_sync_contact_with_hubspot(mock_hubspot_request):
+ """Test that send_hubspot_request is called properly for a CONTACT sync"""
+ user = UserFactory.create()
+ sync_contact_with_hubspot(user.id)
+ body = [make_contact_sync_message(user.id)]
+ body[0]["changeOccurredTimestamp"] = ANY
+ mock_hubspot_request.assert_called_once_with(
+ "CONTACT", HUBSPOT_SYNC_URL, "PUT", body=body
+ )
+
+
+@pytest.mark.django_db
+def test_sync_product_with_hubspot(mock_hubspot_request):
+ """Test that send_hubspot_request is called properly for a PRODUCT sync"""
+ product = ProductFactory.create()
+ sync_product_with_hubspot(product.id)
+ body = [make_product_sync_message(product.id)]
+ body[0]["changeOccurredTimestamp"] = ANY
+ mock_hubspot_request.assert_called_once_with(
+ "PRODUCT", HUBSPOT_SYNC_URL, "PUT", body=body
+ )
+
+
+@pytest.mark.django_db
+def test_sync_deal_with_hubspot(mock_hubspot_request):
+ """Test that send_hubspot_request is called properly for a DEAL sync"""
+ order = OrderFactory.create()
+ sync_deal_with_hubspot(order.id)
+ body = [make_deal_sync_message(order.id)]
+ body[0]["changeOccurredTimestamp"] = ANY
+ mock_hubspot_request.assert_called_once_with(
+ "DEAL", HUBSPOT_SYNC_URL, "PUT", body=body
+ )
+
+
+@pytest.mark.django_db
+def test_sync_line_item_with_hubspot(mock_hubspot_request):
+ """Test that send_hubspot_request is called properly for a LINE_ITEM sync"""
+ line = LineFactory.create()
+ sync_line_item_with_hubspot(line.id)
+ body = [make_line_item_sync_message(line.id)]
+ body[0]["changeOccurredTimestamp"] = ANY
+ mock_hubspot_request.assert_called_once_with(
+ "LINE_ITEM", HUBSPOT_SYNC_URL, "PUT", body=body
+ )
diff --git a/fixtures/common.py b/fixtures/common.py
index 7776f6fc1..092ac58d9 100644
--- a/fixtures/common.py
+++ b/fixtures/common.py
@@ -2,6 +2,7 @@
# pylint: disable=unused-argument, redefined-outer-name
import pytest
+import responses
from django.test.client import Client
from rest_framework.test import APIClient
@@ -32,7 +33,14 @@ def user_drf_client(user):
@pytest.fixture
def admin_drf_client(admin_user):
- """ DRF API test client with admin user """
+ """DRF API test client with admin user """
client = APIClient()
client.force_authenticate(user=admin_user)
return client
+
+
+@pytest.fixture
+def mocked_responses():
+ """Mocked responses for requests library"""
+ with responses.RequestsMock() as rsps:
+ yield rsps
diff --git a/fixtures/cybersource.py b/fixtures/cybersource.py
new file mode 100644
index 000000000..f0daae5c1
--- /dev/null
+++ b/fixtures/cybersource.py
@@ -0,0 +1,35 @@
+"""Fxitures for CyberSource tests"""
+# pylint: disable=redefined-outer-name
+
+from nacl.public import PrivateKey
+import pytest
+
+from compliance.test_utils import (
+ get_cybersource_test_settings,
+ mock_cybersource_wsdl,
+ mock_cybersource_wsdl_operation,
+)
+
+
+@pytest.fixture
+def cybersource_private_key():
+ """Creates a new NaCl private key"""
+ return PrivateKey.generate()
+
+
+@pytest.fixture
+def cybersource_settings(settings, cybersource_private_key):
+ """Configured CyberSource settings"""
+ for attr_name, value in get_cybersource_test_settings(
+ cybersource_private_key
+ ).items():
+ setattr(settings, attr_name, value)
+ return settings
+
+
+@pytest.fixture(params=[])
+def cybersource_mock_client_responses(request, mocked_responses, cybersource_settings):
+ """Mock out the components of a valid WSDL API"""
+ mock_cybersource_wsdl(mocked_responses, cybersource_settings)
+ mock_cybersource_wsdl_operation(mocked_responses, request.param)
+ return mocked_responses
diff --git a/mitxpro/settings.py b/mitxpro/settings.py
index 7ad959abc..41255f72b 100644
--- a/mitxpro/settings.py
+++ b/mitxpro/settings.py
@@ -11,7 +11,7 @@
from mitxpro.envs import get_any, get_bool, get_int, get_string
-VERSION = "0.5.0"
+VERSION = "0.5.1"
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -83,6 +83,7 @@
"mail",
"users",
"cms",
+ "compliance",
"courseware",
# must be after "users" to pick up custom user model
"compat",
@@ -242,10 +243,14 @@
"authentication.pipeline.user.get_username",
# Create a user if one doesn't exist, and require a password and name
"authentication.pipeline.user.create_user_via_email",
+ # verify the user against export compliance
+ "authentication.pipeline.compliance.verify_exports_compliance",
# Create a profile
"authentication.pipeline.user.create_profile",
# Create the record that associates the social account with the user.
"social_core.pipeline.social_auth.associate_user",
+ # activate the user
+ "authentication.pipeline.user.activate_user",
# Populate the extra_data field in the social record with the values
# specified by settings (and the default ones like access_token, etc).
"social_core.pipeline.social_auth.load_extra_data",
@@ -289,14 +294,17 @@
EMAIL_HOST_USER = get_string("MITXPRO_EMAIL_USER", "")
EMAIL_HOST_PASSWORD = get_string("MITXPRO_EMAIL_PASSWORD", "")
EMAIL_USE_TLS = get_bool("MITXPRO_EMAIL_TLS", False)
-EMAIL_SUPPORT = get_string("MITXPRO_SUPPORT_EMAIL", "support@example.com")
+EMAIL_SUPPORT = get_string(
+ "MITXPRO_SUPPORT_EMAIL",
+ get_string("MAILGUN_RECIPIENT_OVERRIDE", "support@localhost"),
+)
DEFAULT_FROM_EMAIL = get_string("MITXPRO_FROM_EMAIL", "webmaster@localhost")
MAILGUN_SENDER_DOMAIN = get_string("MAILGUN_SENDER_DOMAIN", None)
MAILGUN_KEY = get_string("MAILGUN_KEY", None)
MAILGUN_BATCH_CHUNK_SIZE = get_int("MAILGUN_BATCH_CHUNK_SIZE", 1000)
MAILGUN_RECIPIENT_OVERRIDE = get_string("MAILGUN_RECIPIENT_OVERRIDE", None)
-MAILGUN_FROM_EMAIL = get_string("MAILGUN_FROM_EMAIL", "no-reply@example.com")
+MAILGUN_FROM_EMAIL = get_string("MAILGUN_FROM_EMAIL", "no-reply@localhost")
NOTIFICATION_EMAIL_BACKEND = get_string(
"MITXPRO_NOTIFICATION_EMAIL_BACKEND", "anymail.backends.mailgun.EmailBackend"
@@ -549,6 +557,21 @@ def get_all_config_keys():
)
CYBERSOURCE_PROFILE_ID = get_string("CYBERSOURCE_PROFILE_ID", None)
CYBERSOURCE_REFERENCE_PREFIX = get_string("CYBERSOURCE_REFERENCE_PREFIX", None)
+CYBERSOURCE_WSDL_URL = get_string("CYBERSOURCE_WSDL_URL", None)
+CYBERSOURCE_MERCHANT_ID = get_string("CYBERSOURCE_MERCHANT_ID", None)
+CYBERSOURCE_TRANSACTION_KEY = get_string("CYBERSOURCE_TRANSACTION_KEY", None)
+CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY = get_string(
+ "CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY", None
+)
+CYBERSOURCE_EXPORT_SERVICE_ADDRESS_OPERATOR = get_string(
+ "CYBERSOURCE_EXPORT_SERVICE_ADDRESS_OPERATOR", "AND"
+)
+CYBERSOURCE_EXPORT_SERVICE_ADDRESS_WEIGHT = get_string(
+ "CYBERSOURCE_EXPORT_SERVICE_ADDRESS_WEIGHT", "high"
+)
+CYBERSOURCE_EXPORT_SERVICE_NAME_WEIGHT = get_string(
+ "CYBERSOURCE_EXPORT_SERVICE_NAME_WEIGHT", "high"
+)
# Voucher keys for PDF parsing
VOUCHER_DOMESTIC_DATE_KEY = get_string("VOUCHER_DOMESTIC_DATE_KEY", None)
@@ -577,3 +600,5 @@ def get_all_config_keys():
VOUCHER_INTERNATIONAL_COURSE_NUMBER_KEY = get_string(
"VOUCHER_INTERNATIONAL_COURSE_NUMBER_KEY", None
)
+
+HUBSPOT_API_KEY = get_string("HUBSPOT_API_KEY", None)
diff --git a/mitxpro/templates/base.html b/mitxpro/templates/base.html
index 94c52b9fe..9125dc398 100644
--- a/mitxpro/templates/base.html
+++ b/mitxpro/templates/base.html
@@ -1,4 +1,6 @@
{% load static hijack_tags raven render_bundle %}
+{% load wagtailcore_tags render_notification %}
+
@@ -25,6 +27,7 @@
{% include "partials/gtm_body.html" %}
{% hijack_notification %}
+ {% latest_notification %}
{% block headercontent %}
{% endblock %}
{% block content %}
@@ -40,6 +43,7 @@
+
{% endblock %}
diff --git a/mitxpro/templates/partials/notifications.html b/mitxpro/templates/partials/notifications.html
new file mode 100644
index 000000000..87df3ab8c
--- /dev/null
+++ b/mitxpro/templates/partials/notifications.html
@@ -0,0 +1,8 @@
+
+ {% if notification %}
+
+
+ {{ notification|safe }}
+
+ {% endif %}
+
diff --git a/mitxpro/templatetags/render_notification.py b/mitxpro/templatetags/render_notification.py
new file mode 100644
index 000000000..0d69d4111
--- /dev/null
+++ b/mitxpro/templatetags/render_notification.py
@@ -0,0 +1,16 @@
+"""Templatetags for rendering site notification"""
+
+from django import template
+from cms.models import SiteNotification
+
+register = template.Library()
+
+
+@register.inclusion_tag("../templates/partials/notifications.html", takes_context=True)
+def latest_notification(context):
+ """ Return request context and latest notification."""
+
+ return {
+ "notification": SiteNotification.objects.order_by("-id").first(),
+ "request": context["request"],
+ }
diff --git a/mitxpro/views.py b/mitxpro/views.py
index cb7b3684d..90d2ff58d 100644
--- a/mitxpro/views.py
+++ b/mitxpro/views.py
@@ -27,6 +27,7 @@ def get_js_settings_context(request):
"release_version": settings.VERSION,
"recaptchaKey": settings.RECAPTCHA_SITE_KEY,
"sentry_dsn": sentry.get_public_dsn(),
+ "support_email": settings.EMAIL_SUPPORT,
}
return {"js_settings_json": json.dumps(js_settings)}
diff --git a/mitxpro/views_test.py b/mitxpro/views_test.py
index dfc0d4ccc..732a122a1 100644
--- a/mitxpro/views_test.py
+++ b/mitxpro/views_test.py
@@ -29,6 +29,7 @@ def test_webpack_url(mocker, settings, client):
settings.GA_TRACKING_ID = "fake"
settings.ENVIRONMENT = "test"
settings.VERSION = "4.5.6"
+ settings.EMAIL_SUPPORT = "support@text.com"
settings.USE_WEBPACK_DEV_SERVER = False
settings.RECAPTCHA_SITE_KEY = "fake_key"
get_bundle = mocker.patch("mitxpro.templatetags.render_bundle._get_bundle")
@@ -45,6 +46,7 @@ def test_webpack_url(mocker, settings, client):
"sentry_dsn": None,
"release_version": settings.VERSION,
"recaptchaKey": settings.RECAPTCHA_SITE_KEY,
+ "support_email": settings.EMAIL_SUPPORT,
}
diff --git a/pytest.ini b/pytest.ini
index cc348544f..3129cf494 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -19,3 +19,9 @@ env =
OPENEDX_API_BASE_URL=http://localhost:18000
OPENEDX_API_CLIENT_ID=fake_client_id
OPENEDX_API_CLIENT_SECRET=fake_client_secret
+ CYBERSOURCE_WSDL_URL=
+ CYBERSOURCE_MERCHANT_ID=
+ CYBERSOURCE_TRANSACTION_KEY=
+ CYBERSOURCE_INQUIRY_LOG_NACL_ENCRYPTION_KEY=
+ MITXPRO_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
+ MITXPRO_NOTIFICATION_EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
diff --git a/requirements.in b/requirements.in
index 949d05f30..e95eecce6 100644
--- a/requirements.in
+++ b/requirements.in
@@ -21,6 +21,7 @@ pdftotext==2.1.1
Pillow
psycopg2==2.7.3.2
pycountry==18.12.8
+PyNaCl==1.3.0
raven
redis==3.2.1
requests
@@ -28,3 +29,4 @@ social-auth-app-django==3.1.0
ulid-py
uwsgi
wagtail==2.4
+zeep==3.3.1
diff --git a/requirements.txt b/requirements.txt
index 0513de4bc..bc3531ec0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,19 +2,23 @@
# This file is autogenerated by pip-compile
# To update, run:
#
-# pip-compile
+# pip-compile
#
amqp==2.4.2 # via kombu
+appdirs==1.4.3 # via zeep
+attrs==19.1.0 # via zeep
backcall==0.1.0 # via ipython
beautifulsoup4==4.6.0
billiard==3.5.0.5 # via celery
boto3==1.9.115
botocore==1.12.134 # via boto3, s3transfer
+cached-property==1.5.1 # via zeep
celery==4.2.0
certifi==2019.3.9 # via requests
+cffi==1.12.3 # via pynacl
chardet==3.0.4 # via requests
decorator==4.4.0 # via ipython, traitlets
-defusedxml==0.6.0 # via python3-openid, social-auth-core
+defusedxml==0.6.0 # via python3-openid, social-auth-core, zeep
dj-database-url==0.4.2
django-anymail[mailgun]==3.0
django-compat==1.0.15 # via django-hijack, django-hijack-admin
@@ -40,9 +44,11 @@ html5lib==0.999999999
idna==2.8 # via requests
ipython-genutils==0.2.0 # via traitlets
ipython==7.4.0
+isodate==0.6.0 # via zeep
jedi==0.13.3 # via ipython
jmespath==0.9.4 # via boto3, botocore
kombu==4.5.0 # via celery, django-server-status
+lxml==4.3.3 # via zeep
newrelic==4.18.0.118
oauthlib==3.0.1 # via django-oauth-toolkit, requests-oauthlib, social-auth-core
parso==0.4.0 # via jedi
@@ -54,17 +60,20 @@ prompt-toolkit==2.0.9 # via ipython
psycopg2==2.7.3.2
ptyprocess==0.6.0 # via pexpect
pycountry==18.12.8
+pycparser==2.19 # via cffi
pygments==2.3.1 # via ipython
pyjwt==1.7.1 # via social-auth-core
+pynacl==1.3.0
python-dateutil==2.5.3 # via botocore, edx-api-client
python3-openid==3.1.0 # via social-auth-core
-pytz==2019.1 # via celery, django, django-modelcluster, wagtail
+pytz==2019.1 # via celery, django, django-modelcluster, wagtail, zeep
raven==6.10.0
redis==3.2.1
requests-oauthlib==1.2.0 # via social-auth-core
+requests-toolbelt==0.9.1 # via zeep
requests==2.21.0
s3transfer==0.2.0 # via boto3
-six==1.12.0 # via django-anymail, django-compat, django-server-status, edx-api-client, html5lib, prompt-toolkit, python-dateutil, social-auth-app-django, social-auth-core, traitlets, wagtail
+six==1.12.0 # via django-anymail, django-compat, django-server-status, edx-api-client, html5lib, isodate, prompt-toolkit, pynacl, python-dateutil, social-auth-app-django, social-auth-core, traitlets, wagtail, zeep
social-auth-app-django==3.1.0
social-auth-core==3.1.0 # via social-auth-app-django
traitlets==4.3.2 # via ipython
@@ -77,3 +86,4 @@ wagtail==2.4
wcwidth==0.1.7 # via prompt-toolkit
webencodings==0.5.1 # via html5lib
willow==1.1 # via wagtail
+zeep==3.3.1
diff --git a/static/js/containers/pages/CheckoutPage.js b/static/js/containers/pages/CheckoutPage.js
index cbd3aceaf..37188a57b 100644
--- a/static/js/containers/pages/CheckoutPage.js
+++ b/static/js/containers/pages/CheckoutPage.js
@@ -1,9 +1,10 @@
// @flow
import React from "react"
-import * as R from "ramda"
+import { curry } from "ramda"
import { connect } from "react-redux"
-import { connectRequest, mutateAsync } from "redux-query"
+import { mutateAsync, requestAsync } from "redux-query"
import { compose } from "redux"
+import queryString from "query-string"
import queries from "../../lib/queries"
import {
@@ -12,9 +13,10 @@ import {
formatPrice,
formatRunTitle
} from "../../lib/ecommerce"
-import { createCyberSourceForm } from "../../lib/form"
+import { createCyberSourceForm, formatErrors } from "../../lib/form"
import type { Response } from "redux-query"
+import type { Location } from "react-router"
import type {
BasketResponse,
BasketPayload,
@@ -50,6 +52,8 @@ export const calcSelectedRunIds = (item: BasketItem): { [number]: number } => {
type Props = {
basket: ?BasketResponse,
checkout: () => Promise
>,
+ fetchBasket: () => Promise<*>,
+ location: Location,
updateBasket: (payload: BasketPayload) => Promise<*>
}
type State = {
@@ -64,12 +68,44 @@ export class CheckoutPage extends React.Component {
errors: null
}
+ componentDidMount = async () => {
+ const {
+ fetchBasket,
+ location: { search }
+ } = this.props
+ const params = queryString.parse(search)
+ const productId = parseInt(params.product)
+ if (!productId) {
+ await fetchBasket()
+ return
+ }
+
+ try {
+ await this.updateBasket({ items: [{ id: productId }] })
+
+ const couponCode = params.code
+ if (couponCode) {
+ await this.updateBasket({ coupons: [{ code: couponCode }] })
+ }
+ } catch (_) {
+ // prevent complaints about unresolved promises
+ }
+ }
+
handleErrors = async (responsePromise: Promise<*>) => {
const response = await responsePromise
if (response.body.errors) {
this.setState({ errors: response.body.errors })
throw new Error("Received error from request")
}
+
+ // clear state so the state from the basket is used
+ this.setState({
+ couponCode: null,
+ selectedRuns: null,
+ errors: null
+ })
+
return response
}
@@ -117,7 +153,7 @@ export class CheckoutPage extends React.Component {
})
}
- updateSelectedRun = R.curry((courseId: number, event: any) => {
+ updateSelectedRun = curry((courseId: number, event: any) => {
const { selectedRuns } = this.state
const runId = parseInt(event.target.value)
this.setState({
@@ -128,13 +164,34 @@ export class CheckoutPage extends React.Component {
})
})
- submitCoupon = async (e: Event) => {
- const { updateBasket } = this.props
+ getCouponCode = (): string => {
+ const { basket } = this.props
const { couponCode } = this.state
+ if (couponCode !== null) {
+ return couponCode
+ }
+ if (!basket) {
+ return ""
+ }
+
+ const item = basket.items[0]
+ if (!item) {
+ return ""
+ }
+
+ const coupon = basket.coupons.find(coupon =>
+ coupon.targets.includes(item.id)
+ )
+ return (coupon && coupon.code) || ""
+ }
+
+ submitCoupon = async (e: Event) => {
+ const couponCode = this.getCouponCode()
+
e.preventDefault()
- const response = await updateBasket({
+ await this.updateBasket({
coupons: couponCode
? [
{
@@ -143,8 +200,6 @@ export class CheckoutPage extends React.Component {
]
: []
})
- const errors = response.status !== 200 ? response.body.errors : null
- this.setState({ errors })
}
// $FlowFixMe
@@ -200,15 +255,16 @@ export class CheckoutPage extends React.Component {
render() {
const { basket } = this.props
- const { couponCode, errors } = this.state
+ const { errors } = this.state
- if (!basket) {
- return null
- }
-
- const item = basket.items[0]
- if (!item) {
- return No item in basket
+ const item = basket && basket.items[0]
+ if (!basket || !item) {
+ return (
+
+ No item in basket
+ {formatErrors(errors)}
+
+ )
}
const coupon = basket.coupons.find(coupon =>
@@ -244,11 +300,7 @@ export class CheckoutPage extends React.Component {
{
Apply
- {errors ?
Error: {errors}
: null}
+ {formatErrors(errors)}
@@ -302,15 +354,14 @@ const mapStateToProps = state => ({
})
const mapDispatchToProps = dispatch => ({
checkout: () => dispatch(mutateAsync(queries.ecommerce.checkoutMutation())),
+ fetchBasket: () => dispatch(requestAsync(queries.ecommerce.basketQuery())),
updateBasket: payload =>
dispatch(mutateAsync(queries.ecommerce.basketMutation(payload)))
})
-const mapPropsToConfigs = () => [queries.ecommerce.basketQuery()]
export default compose(
connect(
mapStateToProps,
mapDispatchToProps
- ),
- connectRequest(mapPropsToConfigs)
+ )
)(CheckoutPage)
diff --git a/static/js/containers/pages/CheckoutPage_test.js b/static/js/containers/pages/CheckoutPage_test.js
index 16c7549de..58eba7f6c 100644
--- a/static/js/containers/pages/CheckoutPage_test.js
+++ b/static/js/containers/pages/CheckoutPage_test.js
@@ -36,7 +36,9 @@ describe("CheckoutPage", () => {
basket
}
},
- {}
+ {
+ location: {}
+ }
)
})
@@ -102,6 +104,127 @@ describe("CheckoutPage", () => {
assert.equal(inner.find("img").prop("alt"), basketItem.description)
assert.equal(inner.find(".item-row .title").text(), basketItem.description)
})
+ ;[true, false].forEach(hasError => {
+ it(`updates the basket with a product id from the query parameter${
+ hasError ? ", but an error is returned" : ""
+ }`, async () => {
+ const productId = 4567
+ if (hasError) {
+ helper.handleRequestStub.withArgs("/api/basket/", "PATCH").returns({
+ status: 400,
+ body: {
+ errors: "error"
+ }
+ })
+ }
+ const { inner } = await renderPage(
+ {},
+ {
+ location: {
+ search: `product=${productId}`
+ }
+ }
+ )
+
+ sinon.assert.calledWith(
+ helper.handleRequestStub,
+ "/api/basket/",
+ "PATCH",
+ {
+ body: { items: [{ id: productId }] },
+ credentials: undefined,
+ headers: {
+ "X-CSRFTOKEN": null
+ }
+ }
+ )
+ assert.equal(inner.state().errors, hasError ? "error" : null)
+ })
+ })
+
+ describe("mount", () => {
+ const productError = "product error",
+ couponError = "coupon error",
+ couponCode = "codeforcoupon",
+ productId = 12345
+ let couponPayload, productPayload
+
+ beforeEach(() => {
+ couponPayload = {
+ body: { coupons: [{ code: couponCode }] },
+ credentials: undefined,
+ headers: {
+ "X-CSRFTOKEN": null
+ }
+ }
+ productPayload = {
+ body: { items: [{ id: productId }] },
+ credentials: undefined,
+ headers: {
+ "X-CSRFTOKEN": null
+ }
+ }
+ })
+ ;[
+ [true, false, couponError],
+ [true, true, null],
+ [false, false, productError],
+ [false, true, productError]
+ ].forEach(([hasValidProductId, hasValidCoupon, expError]) => {
+ it(`updates the basket with a ${
+ hasValidProductId ? "" : "in"
+ }valid product id and a ${
+ hasValidCoupon ? "" : "in"
+ }valid coupon code from the query parameter`, async () => {
+ if (!hasValidProductId) {
+ helper.handleRequestStub
+ .withArgs("/api/basket/", "PATCH", productPayload)
+ .returns({
+ status: 400,
+ body: {
+ errors: "product error"
+ }
+ })
+ }
+ if (!hasValidCoupon) {
+ helper.handleRequestStub
+ .withArgs("/api/basket/", "PATCH", couponPayload)
+ .returns({
+ status: 400,
+ body: {
+ errors: "coupon error"
+ }
+ })
+ }
+ const { inner } = await renderPage(
+ {},
+ {
+ location: {
+ search: `product=${productId}&code=${couponCode}`
+ }
+ }
+ )
+ // wait for componentDidMount to resolve
+ await Promise.resolve()
+ sinon.assert.calledWith(
+ helper.handleRequestStub,
+ "/api/basket/",
+ "PATCH",
+ productPayload
+ )
+ assert.equal(
+ helper.handleRequestStub.calledWith(
+ "/api/basket/",
+ "PATCH",
+ couponPayload
+ ),
+ hasValidProductId
+ )
+
+ assert.equal(inner.state().errors, expError)
+ })
+ })
+ })
it("displays the coupon code", async () => {
const { inner } = await renderPage()
@@ -153,6 +276,46 @@ describe("CheckoutPage", () => {
})
})
+ it("submits the coupon code currently in the basket if there is none in the state", async () => {
+ const { inner } = await renderPage()
+ const code = basket.coupons[0].code
+ await inner.find(".apply-button").prop("onClick")({
+ preventDefault: helper.sandbox.stub()
+ })
+ sinon.assert.calledWith(helper.handleRequestStub, "/api/basket/", "PATCH", {
+ body: { coupons: [{ code: code }] },
+ credentials: undefined,
+ headers: {
+ "X-CSRFTOKEN": null
+ }
+ })
+ })
+
+ it("clears the state after submitting the basket", async () => {
+ const { inner } = await renderPage()
+ const couponCode = "coupon code"
+ inner.setState({
+ errors: "errors",
+ selectedRuns: ["runs"],
+ couponCode: couponCode
+ })
+ await inner.find(".apply-button").prop("onClick")({
+ preventDefault: helper.sandbox.stub()
+ })
+ sinon.assert.calledWith(helper.handleRequestStub, "/api/basket/", "PATCH", {
+ body: { coupons: [{ code: couponCode }] },
+ credentials: undefined,
+ headers: {
+ "X-CSRFTOKEN": null
+ }
+ })
+ assert.deepEqual(inner.state(), {
+ couponCode: null,
+ errors: null,
+ selectedRuns: null
+ })
+ })
+
it("tries to submit the coupon code but receives an error message", async () => {
const { inner } = await renderPage()
const errors = "Unknown error"
@@ -162,15 +325,14 @@ describe("CheckoutPage", () => {
errors
}
})
- await inner.find("form").prop("onSubmit")({
- preventDefault: helper.sandbox.stub()
- })
+ await assertRaises(async () => {
+ await inner.find("form").prop("onSubmit")({
+ preventDefault: helper.sandbox.stub()
+ })
+ }, "Received error from request")
assert.equal(inner.state().errors, errors)
- assert.equal(
- inner.find(".enrollment-input .error").text(),
- "Error: Unknown error"
- )
+ assert.equal(inner.find(".enrollment-input .error").text(), "Unknown error")
assert.isTrue(inner.find(".enrollment-input input.error-border").exists())
})
diff --git a/static/js/containers/pages/register/RegisterDetailsPage.js b/static/js/containers/pages/register/RegisterDetailsPage.js
index fe2ff64ef..c2a98bd5a 100644
--- a/static/js/containers/pages/register/RegisterDetailsPage.js
+++ b/static/js/containers/pages/register/RegisterDetailsPage.js
@@ -1,4 +1,5 @@
// @flow
+/* global SETTINGS: false */
import React from "react"
import { compose } from "redux"
import { connect } from "react-redux"
@@ -10,7 +11,11 @@ import qs from "query-string"
import auth from "../../../lib/queries/auth"
import users from "../../../lib/queries/users"
import { routes } from "../../../lib/urls"
-import { STATE_REGISTER_EXTRA_DETAILS } from "../../../lib/auth"
+import {
+ STATE_REGISTER_EXTRA_DETAILS,
+ STATE_USER_BLOCKED,
+ STATE_ERROR_TEMPORARY
+} from "../../../lib/auth"
import queries from "../../../lib/queries"
import { qsPartialTokenSelector } from "../../../lib/selectors"
@@ -21,6 +26,7 @@ import type { Response } from "redux-query"
import type {
AuthResponse,
AuthResponseRaw,
+ AuthStates,
LegalAddress,
User,
Country
@@ -36,6 +42,10 @@ type StateProps = {|
countries: Array
|}
+type State = {|
+ authState: ?AuthStates
+|}
+
type DispatchProps = {|
registerDetails: (
name: string,
@@ -52,7 +62,14 @@ type Props = {|
...DispatchProps
|}
-class RegisterProfilePage extends React.Component {
+class RegisterProfilePage extends React.Component {
+ constructor(props) {
+ super(props)
+ this.state = {
+ authState: null
+ }
+ }
+
async onSubmit(detailsData, { setSubmitting, setErrors }) {
const {
history,
@@ -75,6 +92,13 @@ class RegisterProfilePage extends React.Component {
partial_token
})
history.push(`${routes.register.extra}?${params}`)
+ } else if (
+ state === STATE_USER_BLOCKED ||
+ state === STATE_ERROR_TEMPORARY
+ ) {
+ this.setState({
+ authState: state
+ })
} else if (errors.length > 0) {
setErrors({
email: errors[0]
@@ -85,8 +109,33 @@ class RegisterProfilePage extends React.Component {
}
}
+ renderAuthState(authState: AuthStates) {
+ switch (authState) {
+ case STATE_USER_BLOCKED:
+ return (
+
+ )
+ case STATE_ERROR_TEMPORARY:
+ return (
+
+ Unable to complete registration at this time, please try again
+ later.
+
+ )
+ default:
+ return Unknown error, plase contact support
+ }
+ }
+
render() {
const { countries } = this.props
+ const { authState } = this.state
return (
@@ -99,12 +148,16 @@ class RegisterProfilePage extends React.Component
{
Click here
-
-
-
+ {authState ? (
+ this.renderAuthState(authState)
+ ) : (
+
+
+
+ )}
)
}
diff --git a/static/js/flow/authTypes.js b/static/js/flow/authTypes.js
index 93b73503b..344e2b424 100644
--- a/static/js/flow/authTypes.js
+++ b/static/js/flow/authTypes.js
@@ -4,7 +4,10 @@
export type AuthStates =
| "success"
| "inactive"
+ | "invalid-email"
+ | "user-blocked"
| "error"
+ | "error-temporary"
| "login/email"
| "login/password"
| "login/provider"
diff --git a/static/js/flow/declarations.js b/static/js/flow/declarations.js
index 399ffb7e8..b7b35e49a 100644
--- a/static/js/flow/declarations.js
+++ b/static/js/flow/declarations.js
@@ -8,7 +8,8 @@ declare type Settings = {
release_version: string,
environment: string,
gaTrackingID: ?string,
- recaptchaKey: ?string
+ recaptchaKey: ?string,
+ support_email: string,
}
declare var SETTINGS: Settings;
diff --git a/static/js/lib/auth.js b/static/js/lib/auth.js
index f5356053c..20b3d5bfb 100644
--- a/static/js/lib/auth.js
+++ b/static/js/lib/auth.js
@@ -8,9 +8,11 @@ export const FLOW_REGISTER = "register"
export const FLOW_LOGIN = "login"
export const STATE_ERROR = "error"
+export const STATE_ERROR_TEMPORARY = "error-temporary"
export const STATE_SUCCESS = "success"
export const STATE_INACTIVE = "inactive"
export const STATE_INVALID_EMAIL = "invalid-email"
+export const STATE_USER_BLOCKED = "user-blocked"
export const STATE_LOGIN_EMAIL = "login/email"
export const STATE_LOGIN_PASSWORD = "login/password"
diff --git a/static/js/lib/form.js b/static/js/lib/form.js
index e37617bad..df8271422 100644
--- a/static/js/lib/form.js
+++ b/static/js/lib/form.js
@@ -1,5 +1,6 @@
// @flow
import type { CheckoutPayload } from "../flow/ecommerceTypes"
+import React from "react"
/**
* Creates a POST form with hidden input fields
@@ -25,3 +26,23 @@ export function createCyberSourceForm(
}
return form
}
+
+export const formatErrors = (
+ errors: string | Object | null
+): React$Element<*> | null => {
+ if (!errors) {
+ return null
+ }
+
+ let errorString
+ if (typeof errors === "object") {
+ if (errors.items) {
+ errorString = errors.items[0]
+ } else {
+ errorString = errors[0]
+ }
+ } else {
+ errorString = errors
+ }
+ return
{errorString}
+}
diff --git a/static/js/lib/form_test.js b/static/js/lib/form_test.js
index 0b2551113..a68ff7ff2 100644
--- a/static/js/lib/form_test.js
+++ b/static/js/lib/form_test.js
@@ -1,8 +1,9 @@
// @flow
import { assert } from "chai"
+import { shallow } from "enzyme"
import { CYBERSOURCE_CHECKOUT_RESPONSE } from "./test_constants"
-import { createCyberSourceForm } from "./form"
+import { createCyberSourceForm, formatErrors } from "./form"
describe("form functions", () => {
it("creates a form with hidden values corresponding to the payload", () => {
@@ -23,4 +24,27 @@ describe("form functions", () => {
assert.equal(form.getAttribute("action"), url)
assert.equal(form.getAttribute("method"), "post")
})
+
+ describe("formatErrors", () => {
+ it("should return null if there is no error", () => {
+ assert.isNull(formatErrors(null))
+ })
+
+ it("should return a div with the error string if the error is a string", () => {
+ const wrapper = shallow(formatErrors("error"))
+ assert.equal(wrapper.find(".error").text(), "error")
+ })
+
+ it("should return the first item in the items in the error object if it has items", () => {
+ const error = { items: ["error"] }
+ const wrapper = shallow(formatErrors(error))
+ assert.equal(wrapper.find(".error").text(), "error")
+ })
+
+ it("should return the first item in the error if there is no 'items'", () => {
+ const error = ["error"]
+ const wrapper = shallow(formatErrors(error))
+ assert.equal(wrapper.find(".error").text(), "error")
+ })
+ })
})
diff --git a/static/js/notifications.js b/static/js/notifications.js
new file mode 100644
index 000000000..59cdffcdd
--- /dev/null
+++ b/static/js/notifications.js
@@ -0,0 +1,25 @@
+/*eslint-env jquery*/
+/*eslint semi: ["error", "always"]*/
+
+$(document).ready(function() {
+ renderSiteNotification();
+
+ $(".notifications").on("click", ".close-notification", function(e) {
+ e.preventDefault();
+ const $notification = $(this).closest(".notification");
+ localStorage.setItem(
+ "dismissedNotification",
+ $notification.data("notification-id")
+ );
+ $notification.remove();
+ });
+});
+
+function renderSiteNotification() {
+ const notificationId = $(".notification")
+ .data("notification-id")
+ .toString();
+ if (localStorage.getItem("dismissedNotification") !== notificationId) {
+ $(".notifications").removeClass("d-none");
+ }
+}
diff --git a/static/scss/layout.scss b/static/scss/layout.scss
index c11e07884..41f071d19 100644
--- a/static/scss/layout.scss
+++ b/static/scss/layout.scss
@@ -30,6 +30,7 @@
@import "detail/subnav";
@import "ecommerce-admin";
@import "resource";
+@import "notification";
body {
font-family: Rajdhani,"Open Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
diff --git a/static/scss/notification.scss b/static/scss/notification.scss
new file mode 100644
index 000000000..311932f48
--- /dev/null
+++ b/static/scss/notification.scss
@@ -0,0 +1,18 @@
+.notifications {
+ background: $primary;
+ text-align: center;
+ color: white;
+
+ .close-notification {
+ text-decoration: none;
+ color: white;
+ float: right;
+ margin-right: 10px;
+
+ &:after {
+ content: "\e906";
+ font-family: icomoon;
+ margin-left: 10px;
+ }
+ }
+}
diff --git a/test_requirements.txt b/test_requirements.txt
index fde7f3ff5..19910eec3 100644
--- a/test_requirements.txt
+++ b/test_requirements.txt
@@ -10,6 +10,7 @@ ipdb
nplusone>=0.8.1
pdbpp
pip-tools
+hypothesis==4.23.4
pylint==2.1.1
pylint-django==2.0.2
pytest==4.4.1
diff --git a/users/factories.py b/users/factories.py
index 8619ab816..4311fe04d 100644
--- a/users/factories.py
+++ b/users/factories.py
@@ -15,6 +15,8 @@ class UserFactory(DjangoModelFactory):
email = FuzzyText(suffix="@example.com")
name = Faker("name")
+ is_active = True
+
legal_address = RelatedFactory("users.factories.LegalAddressFactory", "user")
class Meta:
diff --git a/users/migrations/0006_user_is_active_false.py b/users/migrations/0006_user_is_active_false.py
new file mode 100644
index 000000000..79d778837
--- /dev/null
+++ b/users/migrations/0006_user_is_active_false.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.7 on 2019-05-15 20:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [("users", "0005_profile")]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="is_active",
+ field=models.BooleanField(
+ default=False, help_text="The user account is active"
+ ),
+ )
+ ]
diff --git a/users/models.py b/users/models.py
index 474b446cb..5a89e63b6 100644
--- a/users/models.py
+++ b/users/models.py
@@ -79,6 +79,7 @@ def create_superuser(self, username, email, password, **extra_fields):
"""Create a superuser"""
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
+ extra_fields.setdefault("is_active", True)
if extra_fields.get("is_staff") is not True:
raise ValueError("Superuser must have is_staff=True.")
@@ -107,7 +108,7 @@ class User(AbstractBaseUser, TimestampedModel, PermissionsMixin):
default=False, help_text="The user can access the admin site"
)
is_active = models.BooleanField(
- default=True, help_text="The user account is active"
+ default=False, help_text="The user account is active"
)
objects = UserManager()
diff --git a/users/models_test.py b/users/models_test.py
index 4018865e6..9edcd394d 100644
--- a/users/models_test.py
+++ b/users/models_test.py
@@ -9,10 +9,10 @@
@pytest.mark.parametrize(
- "create_func,exp_staff,exp_superuser",
+ "create_func,exp_staff,exp_superuser,exp_is_active",
[
- [User.objects.create_user, False, False],
- [User.objects.create_superuser, True, True],
+ [User.objects.create_user, False, False, False],
+ [User.objects.create_superuser, True, True, True],
],
)
@pytest.mark.parametrize("username", [None, "user1"])
@@ -21,10 +21,11 @@ def test_create_user(
create_func,
exp_staff,
exp_superuser,
+ exp_is_active,
username,
password,
patch_create_edx_user_task,
-):
+): # pylint: disable=too-many-arguments
"""Test creating a user"""
email = "uSer@EXAMPLE.com"
name = "Jane Doe"
@@ -39,7 +40,7 @@ def test_create_user(
assert user.get_full_name() == name
assert user.is_staff is exp_staff
assert user.is_superuser is exp_superuser
- assert user.is_active is True
+ assert user.is_active is exp_is_active
if password is not None:
assert user.check_password(password)
patch_create_edx_user_task.delay.assert_called_once_with(user.id)
diff --git a/users/serializers.py b/users/serializers.py
index 3fbecc9a9..e7db6bada 100644
--- a/users/serializers.py
+++ b/users/serializers.py
@@ -127,6 +127,29 @@ class Meta:
}
+class ProfileSerializer(serializers.ModelSerializer):
+ """Serializer for Profile """
+
+ class Meta:
+ model = Profile
+ fields = (
+ "id",
+ "user",
+ "birth_year",
+ "gender",
+ "company",
+ "company_size",
+ "industry",
+ "job_title",
+ "job_function",
+ "years_experience",
+ "leadership_level",
+ "created_on",
+ "updated_on",
+ )
+ read_only_fields = ("created_on", "updated_on")
+
+
class PublicUserSerializer(serializers.ModelSerializer):
"""Serializer for public user data"""
@@ -143,6 +166,7 @@ class UserSerializer(serializers.ModelSerializer):
email = WriteableSerializerMethodField()
username = WriteableSerializerMethodField()
legal_address = LegalAddressSerializer(allow_null=True)
+ profile = ProfileSerializer(allow_null=True, required=False)
def validate_email(self, value):
"""Empty validation function, but this is required for WriteableSerializerMethodField"""
@@ -164,6 +188,7 @@ def get_username(self, instance):
def create(self, validated_data):
"""Create a new user"""
legal_address_data = validated_data.pop("legal_address")
+ profile_data = validated_data.pop("profile", None)
username = validated_data.pop("username")
email = validated_data.pop("email")
@@ -173,7 +198,7 @@ def create(self, validated_data):
username, email=email, password=password, **validated_data
)
- # this side-effects such that user.legal_address is updated in-place
+ # this side-effects such that user.legal_address and user.profile are updated in-place
if legal_address_data:
legal_address = LegalAddressSerializer(
user.legal_address, data=legal_address_data
@@ -181,22 +206,35 @@ def create(self, validated_data):
if legal_address.is_valid():
legal_address.save()
+ if profile_data:
+ profile = ProfileSerializer(user.profile, data=profile_data)
+ if profile.is_valid():
+ profile.save()
+
return user
@transaction.atomic
def update(self, instance, validated_data):
"""Update an existing user"""
legal_address_data = validated_data.pop("legal_address", None)
+ profile_data = validated_data.pop("profile", None)
password = validated_data.pop("password", None)
+ # this side-effects such that user.legal_address and user.profile are updated in-place
if legal_address_data:
- # this side-effects such that instance.legal_address is updated in-place
address_serializer = LegalAddressSerializer(
instance.legal_address, data=legal_address_data
)
if address_serializer.is_valid(raise_exception=True):
address_serializer.save()
+ if profile_data:
+ profile_serializer = LegalAddressSerializer(
+ instance.profile, data=profile_data
+ )
+ if profile_serializer.is_valid(raise_exception=True):
+ profile_serializer.save()
+
# save() will be called in super().update()
if password is not None:
instance.set_password(password)
@@ -212,6 +250,7 @@ class Meta:
"email",
"password",
"legal_address",
+ "profile",
"is_anonymous",
"is_authenticated",
"created_on",
@@ -226,29 +265,6 @@ class Meta:
)
-class ProfileSerializer(serializers.ModelSerializer):
- """Serializer for Profile """
-
- class Meta:
- model = Profile
- fields = (
- "id",
- "user",
- "birth_year",
- "gender",
- "company",
- "company_size",
- "industry",
- "job_title",
- "job_function",
- "years_experience",
- "leadership_level",
- "created_on",
- "updated_on",
- )
- read_only_fields = ("created_on", "updated_on")
-
-
class StateProvinceSerializer(serializers.Serializer):
""" Serializer for pycountry states/provinces"""
diff --git a/users/views_test.py b/users/views_test.py
index 614b52ed3..d5168abdd 100644
--- a/users/views_test.py
+++ b/users/views_test.py
@@ -55,6 +55,7 @@ def test_get_user_by_me(client, user, is_anonymous):
"legal_address": None,
"is_anonymous": True,
"is_authenticated": False,
+ "profile": None,
}
if is_anonymous
else {