From acc703e43bcd0e28021acaf9c2ec858f632628ea Mon Sep 17 00:00:00 2001 From: Rebecca David Date: Mon, 8 May 2023 15:04:11 -0400 Subject: [PATCH 1/5] Merge pull request #153 from CUCWD/feature.maple/ztraboo/feat-bigcommerce-merge-prior-release feat(bigcommerce_app) Merge in prior release changes. --- .github/workflows/pylint-checks.yml | 2 +- .github/workflows/unit-test-shards.json | 1 + .gitignore | 4 +- common/djangoapps/student/views/dashboard.py | 4 + common/djangoapps/third_party_auth/admin.py | 21 + .../migrations/0006_emailproviderconfig.py | 50 +++ .../migrations/0007_auto_20220121_1956.py | 23 + common/djangoapps/third_party_auth/models.py | 91 ++++ .../djangoapps/third_party_auth/provider.py | 13 + .../djangoapps/third_party_auth/strategy.py | 46 +- db_keyword_overrides.yml | 1 + lms/djangoapps/bigcommerce_app/__init__.py | 0 lms/djangoapps/bigcommerce_app/apps.py | 21 + .../bigcommerce_app/callbacks/__init__.py | 0 .../bigcommerce_app/callbacks/urls.py | 42 ++ .../bigcommerce_app/callbacks/views.py | 286 ++++++++++++ .../bigcommerce_app/events/__init__.py | 0 .../events/course_enrollment.py | 14 + lms/djangoapps/bigcommerce_app/handlers.py | 29 ++ .../migrations/0001_initial.py | 67 +++ .../migrations/0002_auto_20211015_1907.py | 23 + .../migrations/0003_auto_20211015_1936.py | 23 + .../migrations/0004_auto_20211015_1946.py | 28 ++ .../migrations/0005_auto_20211019_0209.py | 30 ++ .../migrations/0006_auto_20211021_1916.py | 25 ++ .../migrations/0007_auto_20211021_2015.py | 28 ++ .../migrations/0008_auto_20211021_2019.py | 18 + .../bigcommerce_app/migrations/__init__.py | 0 lms/djangoapps/bigcommerce_app/models.py | 137 ++++++ .../bigcommerce_app/single_click/__init__.py | 0 .../bigcommerce_app/single_click/urls.py | 18 + .../bigcommerce_app/single_click/views.py | 64 +++ .../bigcommerce_app/tests/__init__.py | 0 .../bigcommerce_app/tests/factories.py | 106 +++++ .../bigcommerce_app/tests/test_models.py | 171 +++++++ lms/djangoapps/bigcommerce_app/utils.py | 418 ++++++++++++++++++ lms/envs/common.py | 38 ++ lms/envs/test.py | 18 + .../views/account_settings_factory.js | 104 +++-- .../views/account_settings_view.js | 10 +- lms/templates/bigcommerce_app/index.html | 6 + .../single-click-app-base.html | 56 +++ .../student_account/account_settings.html | 1 + lms/urls.py | 21 + .../user_api/accounts/settings_views.py | 11 +- openedx/core/djangoapps/user_authn/cookies.py | 5 +- requirements/edx/development.txt | 6 + requirements/edx/private.in | 4 + requirements/edx/private.txt | 51 +++ requirements/edx/testing.in | 1 + requirements/edx/testing.txt | 6 + 51 files changed, 2082 insertions(+), 60 deletions(-) create mode 100644 common/djangoapps/third_party_auth/migrations/0006_emailproviderconfig.py create mode 100644 common/djangoapps/third_party_auth/migrations/0007_auto_20220121_1956.py create mode 100644 lms/djangoapps/bigcommerce_app/__init__.py create mode 100644 lms/djangoapps/bigcommerce_app/apps.py create mode 100644 lms/djangoapps/bigcommerce_app/callbacks/__init__.py create mode 100644 lms/djangoapps/bigcommerce_app/callbacks/urls.py create mode 100644 lms/djangoapps/bigcommerce_app/callbacks/views.py create mode 100644 lms/djangoapps/bigcommerce_app/events/__init__.py create mode 100644 lms/djangoapps/bigcommerce_app/events/course_enrollment.py create mode 100644 lms/djangoapps/bigcommerce_app/handlers.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0001_initial.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0002_auto_20211015_1907.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0003_auto_20211015_1936.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0004_auto_20211015_1946.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0005_auto_20211019_0209.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0006_auto_20211021_1916.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0007_auto_20211021_2015.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0008_auto_20211021_2019.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/__init__.py create mode 100644 lms/djangoapps/bigcommerce_app/models.py create mode 100644 lms/djangoapps/bigcommerce_app/single_click/__init__.py create mode 100644 lms/djangoapps/bigcommerce_app/single_click/urls.py create mode 100644 lms/djangoapps/bigcommerce_app/single_click/views.py create mode 100644 lms/djangoapps/bigcommerce_app/tests/__init__.py create mode 100644 lms/djangoapps/bigcommerce_app/tests/factories.py create mode 100644 lms/djangoapps/bigcommerce_app/tests/test_models.py create mode 100644 lms/djangoapps/bigcommerce_app/utils.py create mode 100644 lms/templates/bigcommerce_app/index.html create mode 100644 lms/templates/bigcommerce_app/single-click-app-base.html create mode 100644 requirements/edx/private.in create mode 100644 requirements/edx/private.txt diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 344119d3122a..7fdafa275a34 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -15,7 +15,7 @@ jobs: matrix: include: - module-name: lms-1 - path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/" + path: "lms/djangoapps/badges/ lms/djangoapps/bigcommerce_app/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/dashboard/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" - module-name: lms-2 path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 838d0fe261f2..76bc60f9f638 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -3,6 +3,7 @@ "settings": "lms.envs.test", "paths": [ "lms/djangoapps/badges/", + "lms/djangoapps/bigcommerce_app/", "lms/djangoapps/branding/", "lms/djangoapps/bulk_email/", "lms/djangoapps/bulk_enroll/", diff --git a/.gitignore b/.gitignore index db305cfcdee0..243224a28ab3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,8 @@ # and so should not be destroyed by "make clean". # start-noclean requirements/private.txt -requirements/edx/private.in -requirements/edx/private.txt +# requirements/edx/private.in +# requirements/edx/private.txt lms/envs/private.py cms/envs/private.py # end-noclean diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index a5d9cf02a465..342fefe24e57 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -31,6 +31,7 @@ from lms.djangoapps.courseware.access import has_access from lms.djangoapps.experiments.utils import get_dashboard_course_info, get_experiment_user_metadata_context from lms.djangoapps.verify_student.services import IDVerificationService +from lms.djangoapps.bigcommerce_app.utils import BigCommerceAPI from openedx.core.djangoapps.catalog.utils import ( get_programs, get_pseudo_session_for_entitlement, @@ -167,6 +168,9 @@ def get_course_enrollments(user, org_whitelist, org_blacklist, course_limit=None generator[CourseEnrollment]: a sequence of enrollments to be displayed on the user's dashboard. """ + + BigCommerceAPI.get_bc_course_enrollments(user) + for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user, course_limit): # If the course is missing or broken, log an error and skip it. diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index 35f43ad1296c..4853b971d8e4 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -12,8 +12,10 @@ from django.utils.translation import gettext_lazy as _ from .models import ( + _PSA_EMAIL_BACKENDS, _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS, + EmailProviderConfig, LTIProviderConfig, OAuth2ProviderConfig, SAMLConfiguration, @@ -23,6 +25,25 @@ from .tasks import fetch_saml_metadata +class EmailProviderConfigForm(forms.ModelForm): + """ Django Admin form class for EmailProviderConfig """ + backend_name = forms.ChoiceField(choices=((name, name) for name in _PSA_EMAIL_BACKENDS)) + + +class EmailProviderConfigAdmin(KeyedConfigurationModelAdmin): + """ Django Admin class for EmailProviderConfig """ + form = EmailProviderConfigForm + + def get_list_display(self, request): + """ Don't show every single field in the admin change list """ + return ( + 'name', 'enabled', 'slug', 'site', 'backend_name', 'secondary', 'skip_registration_form', + 'skip_email_verification', 'change_date', 'changed_by', 'edit_link', + ) + +admin.site.register(EmailProviderConfig, EmailProviderConfigAdmin) + + class OAuth2ProviderConfigForm(forms.ModelForm): """ Django Admin form class for OAuth2ProviderConfig """ backend_name = forms.ChoiceField(choices=((name, name) for name in _PSA_OAUTH2_BACKENDS)) diff --git a/common/djangoapps/third_party_auth/migrations/0006_emailproviderconfig.py b/common/djangoapps/third_party_auth/migrations/0006_emailproviderconfig.py new file mode 100644 index 000000000000..3d830a001265 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0006_emailproviderconfig.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.10 on 2022-01-14 20:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('organizations', '0003_historicalorganizationcourse'), + ('third_party_auth', '0005_auto_20210723_1527'), + ] + + operations = [ + migrations.CreateModel( + name='EmailProviderConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('icon_class', models.CharField(blank=True, default='fa-sign-in', help_text='The Font Awesome (or custom) icon class to use on the login button for this provider. Examples: fa-google-plus, fa-facebook, fa-linkedin, fa-sign-in, fa-university', max_length=50)), + ('icon_image', models.FileField(blank=True, help_text='If there is no Font Awesome icon available for this provider, upload a custom image. SVG images are recommended as they can scale to any size.', upload_to='')), + ('name', models.CharField(help_text='Name of this provider (shown to users)', max_length=50)), + ('slug', models.SlugField(default='default', help_text='A short string uniquely identifying this provider. Cannot contain spaces and should be a usable as a CSS class. Examples: "ubc", "mit-staging"', max_length=30)), + ('secondary', models.BooleanField(default=False, help_text='Secondary providers are displayed less prominently, in a separate list of "Institution" login providers.')), + ('skip_hinted_login_dialog', models.BooleanField(default=False, help_text='If this option is enabled, users that visit a "TPA hinted" URL for this provider (e.g. a URL ending with `?tpa_hint=[provider_name]`) will be forwarded directly to the login URL of the provider instead of being first prompted with a login dialog.')), + ('skip_registration_form', models.BooleanField(default=False, help_text='If this option is enabled, users will not be asked to confirm their details (name, email, etc.) during the registration process. Only select this option for trusted providers that are known to provide accurate user information.')), + ('skip_email_verification', models.BooleanField(default=False, help_text='If this option is selected, users will not be required to confirm their email, and their account will be activated immediately upon registration.')), + ('send_welcome_email', models.BooleanField(default=False, help_text='If this option is selected, users will be sent a welcome email upon registration.')), + ('visible', models.BooleanField(default=False, help_text='If this option is not selected, users will not be presented with the provider as an option to authenticate with on the login screen, but manual authentication using the correct link is still possible.')), + ('max_session_length', models.PositiveIntegerField(blank=True, default=None, help_text='If this option is set, then users logging in using this SSO provider will have their session length limited to no longer than this value. If set to 0 (zero), the session will expire upon the user closing their browser. If left blank, the Django platform session default length will be used.', null=True, verbose_name='Max session length (seconds)')), + ('send_to_registration_first', models.BooleanField(default=False, help_text='If this option is selected, users will be directed to the registration page immediately after authenticating with the third party instead of the login page.')), + ('sync_learner_profile_data', models.BooleanField(default=False, help_text='Synchronize user profile data received from the identity provider with the edX user account on each SSO login. The user will be notified if the email address associated with their account is changed as a part of this synchronization.')), + ('enable_sso_id_verification', models.BooleanField(default=False, help_text='Use the presence of a profile from a trusted third party as proof of identity verification.')), + ('disable_for_enterprise_sso', models.BooleanField(default=False, help_text='IDPs with this set to True will be excluded from the dropdown IDP selection in the EnterpriseCustomer Django Admin form.', verbose_name='Disabled for Enterprise TPA')), + ('backend_name', models.CharField(db_index=True, help_text='Which python-social-auth Email provider backend to use. The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting.', max_length=50)), + ('other_settings', models.TextField(blank=True, help_text='Optional JSON object with advanced settings, if any.')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ('organization', models.ForeignKey(blank=True, help_text='optional. If this provider is an Organization, this attribute can be used reference users in that Organization', null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.organization')), + ('site', models.ForeignKey(default=1, help_text='The Site that this provider configuration belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='emailproviderconfigs', to='sites.site')), + ], + options={ + 'verbose_name': 'Provider Configuration (Email)', + 'verbose_name_plural': 'Provider Configuration (Email)', + }, + ), + ] diff --git a/common/djangoapps/third_party_auth/migrations/0007_auto_20220121_1956.py b/common/djangoapps/third_party_auth/migrations/0007_auto_20220121_1956.py new file mode 100644 index 000000000000..8367d41f5f00 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0007_auto_20220121_1956.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.10 on 2022-01-21 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0006_emailproviderconfig'), + ] + + operations = [ + migrations.AddField( + model_name='emailproviderconfig', + name='key', + field=models.TextField(blank=True, verbose_name='Client ID'), + ), + migrations.AddField( + model_name='emailproviderconfig', + name='secret', + field=models.TextField(blank=True, help_text='For increased security, you can avoid storing this in your database by leaving this field blank and setting SOCIAL_AUTH_OAUTH_SECRETS = {"(backend name)": "secret", ...} in your instance\'s Django settings (or lms.auth.json)', verbose_name='Client Secret'), + ), + ] diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index f0a37616a3d1..3288974c9d6d 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -17,11 +17,14 @@ from django.utils.translation import gettext_lazy as _ from organizations.models import Organization from social_core.backends.base import BaseAuth +from social_core.backends.email import EmailAuth from social_core.backends.oauth import OAuthAuth from social_core.backends.saml import SAMLAuth from social_core.exceptions import SocialAuthBaseException from social_core.utils import module_member +from lms.djangoapps.bigcommerce_app.utils import access_token, BigCommerceAPI, internal_server_error + from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming.helpers import get_current_request from openedx.core.djangoapps.user_api.accounts import USERNAME_MAX_LENGTH @@ -48,6 +51,7 @@ def _load_backend_classes(base_class=BaseAuth): if issubclass(auth_class, base_class): yield auth_class _PSA_BACKENDS = {backend_class.name: backend_class for backend_class in _load_backend_classes()} +_PSA_EMAIL_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(EmailAuth)] _PSA_OAUTH2_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(OAuthAuth)] _PSA_SAML_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(SAMLAuth)] _LTI_BACKENDS = [backend_class.name for backend_class in _load_backend_classes(LTIAuthBackend)] @@ -354,6 +358,93 @@ def enabled_for_current_site(self): return self.enabled and self.site_id == Site.objects.get_current(get_current_request()).id +class EmailProviderConfig(ProviderConfig): + """ + Configuration Entry for an Email based provider. + + .. no_pii: + """ + # We are keying the provider config by backend_name here as suggested in the python social + # auth documentation. In order to reuse a backend for a second provider, a subclass can be + # created with seperate name. + # example: + # class SecondEmailProvider(EmailAuth): + # name = "second-email-provider" + KEY_FIELDS = ('backend_name',) + prefix = 'email' + backend_name = models.CharField( + max_length=50, blank=False, db_index=True, + help_text=( + u"Which python-social-auth Email provider backend to use. " + "The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting." + # To be precise, it's set by AUTHENTICATION_BACKENDS + # which production.py sets from THIRD_PARTY_AUTH_BACKENDS + ) + ) + key = models.TextField(blank=True, verbose_name=u"Client ID") + secret = models.TextField( + blank=True, + verbose_name=u"Client Secret", + help_text=( + u'For increased security, you can avoid storing this in your database by leaving ' + ' this field blank and setting ' + 'SOCIAL_AUTH_OAUTH_SECRETS = {"(backend name)": "secret", ...} ' + 'in your instance\'s Django settings (or lms.auth.json)' + ) + ) + other_settings = models.TextField(blank=True, help_text=u"Optional JSON object with advanced settings, if any.") + + class Meta(object): + app_label = "third_party_auth" + verbose_name = u"Provider Configuration (Email)" + verbose_name_plural = verbose_name + + def clean(self): + """ Standardize and validate fields """ + super().clean() + self.other_settings = clean_json(self.other_settings, dict) + + def get_setting(self, name): + """ Get the value of a setting, or raise KeyError """ + if name == "KEY": + return self.key + if name == "SECRET": + if self.secret: + return self.secret + # To allow instances to avoid storing secrets in the DB, the secret can also be set via Django: + return getattr(settings, 'SOCIAL_AUTH_OAUTH_SECRETS', {}).get(self.backend_name, '') + if name == "ACCESS_TOKEN": + return access_token() + # if name == "BIGCOMMERCE_CUSTOMER_METADATA": + # import pdb;pdb.set_trace() + # return BigCommerceAPI.bigcommerce_customer_metadata() + if self.other_settings: + other_settings = json.loads(self.other_settings) + assert isinstance(other_settings, dict), "other_settings should be a JSON object (dictionary)" + return other_settings[name] + raise KeyError + + def bigcommerce_retrieve_and_store_customer(self, payload): + """ Get the decoded payload user_data """ + try: + return BigCommerceAPI.bigcommerce_customer_save(payload) + except Exception as excep: # pylint: disable=broad-except + log.error( + u"Error decoding Customer payload token and storing in database." + ) + return internal_server_error(excep) + + def bigcommerce_save_store_customer_platform_user(self, payload): + """ Store the mapping of BigCommerce uid and platform user. """ + try: + return BigCommerceAPI.bigcommerce_store_customer_platform_user_save(payload) + except Exception as excep: # pylint: disable=broad-except + log.error( + u"Error decoding Customer payload token and storing in database." + ) + return internal_server_error(excep) + + class OAuth2ProviderConfig(ProviderConfig): """ Configuration Entry for an OAuth2 based provider. diff --git a/common/djangoapps/third_party_auth/provider.py b/common/djangoapps/third_party_auth/provider.py index 5eaf0f1888de..c036ebff0d32 100644 --- a/common/djangoapps/third_party_auth/provider.py +++ b/common/djangoapps/third_party_auth/provider.py @@ -9,8 +9,10 @@ from common.djangoapps.third_party_auth.models import ( _LTI_BACKENDS, + _PSA_EMAIL_BACKENDS, _PSA_OAUTH2_BACKENDS, _PSA_SAML_BACKENDS, + EmailProviderConfig, LTIProviderConfig, OAuth2ProviderConfig, SAMLConfiguration, @@ -30,6 +32,11 @@ def _enabled_providers(cls): Helper method that returns a generator used to iterate over all providers of the current site. """ + email_backend_names = EmailProviderConfig.key_values('backend_name', flat=True) + for email_backend_name in email_backend_names: + provider = EmailProviderConfig.current(email_backend_name) + if provider.enabled_for_current_site and provider.backend_name in _PSA_EMAIL_BACKENDS: + yield provider oauth2_backend_names = OAuth2ProviderConfig.key_values('backend_name', flat=True) for oauth2_backend_name in oauth2_backend_names: provider = OAuth2ProviderConfig.current(oauth2_backend_name) @@ -113,6 +120,12 @@ def get_enabled_by_backend_name(cls, backend_name): Yields: Instances of ProviderConfig. """ + if backend_name in _PSA_EMAIL_BACKENDS: + email_backend_names = EmailProviderConfig.key_values('backend_name', flat=True) + for email_backend_name in email_backend_names: + provider = EmailProviderConfig.current(email_backend_name) + if provider.backend_name == backend_name and provider.enabled_for_current_site: + yield provider if backend_name in _PSA_OAUTH2_BACKENDS: oauth2_backend_names = OAuth2ProviderConfig.key_values('backend_name', flat=True) for oauth2_backend_name in oauth2_backend_names: diff --git a/common/djangoapps/third_party_auth/strategy.py b/common/djangoapps/third_party_auth/strategy.py index a9800c871254..59fccf1c3dcf 100644 --- a/common/djangoapps/third_party_auth/strategy.py +++ b/common/djangoapps/third_party_auth/strategy.py @@ -3,11 +3,11 @@ ConfigurationModels rather than django.settings """ - +from social_core.backends.email import EmailAuth from social_core.backends.oauth import OAuthAuth from social_django.strategy import DjangoStrategy -from .models import OAuth2ProviderConfig +from .models import EmailProviderConfig, OAuth2ProviderConfig from .pipeline import AUTH_ENTRY_CUSTOM from .pipeline import get as get_pipeline_from_request from .provider import Registry @@ -23,12 +23,22 @@ def setting(self, name, default=None, backend=None): Load the setting from a ConfigurationModel if possible, or fall back to the normal Django settings lookup. + EmailAuth subclasses will call this method for every setting they want to look up. OAuthAuth subclasses will call this method for every setting they want to look up. SAMLAuthBackend subclasses will call this method only after first checking if the setting 'name' is configured via SAMLProviderConfig. LTIAuthBackend subclasses will call this method only after first checking if the setting 'name' is configured via LTIProviderConfig. """ + if isinstance(backend, EmailAuth): + provider_config = EmailProviderConfig.current(backend.name) + if not provider_config.enabled_for_current_site: + raise Exception("Can't fetch setting of a disabled backend/provider.") + try: + return provider_config.get_setting(name) + except KeyError: + pass + if isinstance(backend, OAuthAuth): provider_config = OAuth2ProviderConfig.current(backend.name) if not provider_config.enabled_for_current_site: @@ -58,3 +68,35 @@ def setting(self, name, default=None, backend=None): # At this point, we know 'name' is not set in a [OAuth2|LTI|SAML]ProviderConfig row. # It's probably a global Django setting like 'FIELDS_STORED_IN_SESSION': return super().setting(name, default, backend) + + def bigcommerce_retrieve_and_store_customer(self, payload=None, backend=None): + """ + Load the payload user_data decoded values from a ConfigurationModel if possible, or fall back to the normal + Django settings lookup. + + EmailAuth subclasses will call this method for every setting they want to look up. + """ + if isinstance(backend, EmailAuth): + provider_config = EmailProviderConfig.current(backend.name) + if not provider_config.enabled_for_current_site: + raise Exception("Can't decode payload of a disabled backend/provider.") + try: + return provider_config.bigcommerce_retrieve_and_store_customer(payload) + except KeyError: + pass + + def bigcommerce_save_store_customer_platform_user(self, payload=None, backend=None): + """ + Load the payload user_data decoded values from a ConfigurationModel if possible, or fall back to the normal + Django settings lookup. + + EmailAuth subclasses will call this method for every setting they want to look up. + """ + if isinstance(backend, EmailAuth): + provider_config = EmailProviderConfig.current(backend.name) + if not provider_config.enabled_for_current_site: + raise Exception("Can't decode payload of a disabled backend/provider.") + try: + return provider_config.bigcommerce_save_store_customer_platform_user(payload) + except KeyError: + pass diff --git a/db_keyword_overrides.yml b/db_keyword_overrides.yml index 76d33a1e84ff..869a09796e0e 100644 --- a/db_keyword_overrides.yml +++ b/db_keyword_overrides.yml @@ -9,6 +9,7 @@ MYSQL: - CornerstoneGlobalConfiguration.key - CourseCompleteImageConfiguration.default - DegreedEnterpriseCustomerConfiguration.key + - EmailProviderConfig.key - ExperimentData.key - ExperimentKeyValue.key - GeneratedCertificate.key diff --git a/lms/djangoapps/bigcommerce_app/__init__.py b/lms/djangoapps/bigcommerce_app/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/bigcommerce_app/apps.py b/lms/djangoapps/bigcommerce_app/apps.py new file mode 100644 index 000000000000..76960a60e885 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/apps.py @@ -0,0 +1,21 @@ +""" +BigCommerce Application Configuration + +Signal handlers are connected here. +""" + + +from django.apps import AppConfig + + +class BigCommerceAppConfig(AppConfig): + """ + Application Configuration for BigCommerce. + """ + name = u'lms.djangoapps.bigcommerce_app' + + def ready(self): + """ + Connect signal handlers. + """ + from . import handlers # pylint: disable=unused-import diff --git a/lms/djangoapps/bigcommerce_app/callbacks/__init__.py b/lms/djangoapps/bigcommerce_app/callbacks/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/bigcommerce_app/callbacks/urls.py b/lms/djangoapps/bigcommerce_app/callbacks/urls.py new file mode 100644 index 000000000000..db5b32d57245 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/callbacks/urls.py @@ -0,0 +1,42 @@ +""" +BigCommerce Callback URLs for Single-Click app. +""" + +from django.urls import re_path + +from lms.djangoapps.bigcommerce_app.callbacks import views + +APP_NAME = 'v1' +urlpatterns = [] + +urlpatterns += [ + re_path( + r'auth/$', + views.BigCommerceAppCallbacks.auth, + name='auth' + ), +] + +urlpatterns += [ + re_path( + r'load/$', + views.BigCommerceAppCallbacks.load, + name='load' + ), +] + +urlpatterns += [ + re_path( + r'uninstall/$', + views.BigCommerceAppCallbacks.uninstall, + name='uninstall' + ), +] + +urlpatterns += [ + re_path( + r'remove-user/$', + views.BigCommerceAppCallbacks.remove_user, + name='remove-user' + ), +] diff --git a/lms/djangoapps/bigcommerce_app/callbacks/views.py b/lms/djangoapps/bigcommerce_app/callbacks/views.py new file mode 100644 index 000000000000..a8d91500e719 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/callbacks/views.py @@ -0,0 +1,286 @@ +""" +BigCommerce Single-Click app endpoints +""" + +import logging + +from django.http import HttpResponse +from django.shortcuts import redirect, reverse +# from django.urls import reverse + +from lms.djangoapps.bigcommerce_app.models import Store, AdminUser, StoreAdminUser +from lms.djangoapps.bigcommerce_app.utils import ( + internal_server_error, + client_id, + client_secret, + platform_lms_url +) + +from bigcommerce.api import BigcommerceApi + +LOGGER = logging.getLogger(__name__) + + +class BigCommerceAppCallbacks(): + """ + Handles all the Single-Click app (SCa) Callback Urls + - Auth Callback URL + - Load Callback URL + - Uninstall Callback URL + - Remove User Callback URL (when SCa has Multiple Users enabled) + + These callback URLs will be called during the OAuth registration process, or when the user + uninstalls your app. Importantly, communication with these endpoints must be done over HTTPS. + """ + + @classmethod + def auth(cls, request): + """ + The GET request to your app’s auth callback URL contains a temporary code that can be + exchanged for a permanent access_token. It also includes a unique value that identifies + the store installing or updating your app, as well as authorized scopes. + + https://developer.bigcommerce.com/api-docs/apps/guide/auth#receiving-the-get-request + """ + LOGGER.info( + "Initiated single-click app `auth_callback` from BigCommerce" + ) + + code = request.GET.get('code') + context = request.GET.get('context') + scope = request.GET.get('scope') + store_hash = context.split('/')[1] if context else '' + + # Should be same as the BigCommerce Single-Click app Auth Callback URL + auth_redirect = \ + f"{platform_lms_url()}{reverse('bigcommerce_app_callbacks:auth')}".rstrip("/") + + LOGGER.info( + "The `auth_redirect` is %s", + auth_redirect + ) + + try: + # Fetch a permanent oauth token. This will throw an exception on error, + # which will get caught by our error handler above. + client = BigcommerceApi(client_id=client_id(), store_hash=store_hash) + + token = client.oauth_fetch_token(client_secret(), code, context, scope, auth_redirect) + bc_store_admin_user_id = token['user']['id'] + bc_store_admin_email = token['user']['email'] + access_token = token['access_token'] + except Exception as excep: # lint-amnesty, pylint: disable=broad-except + LOGGER.error( + "Could not get access token from BigCommerce in `auth_callback`\n%s", + excep + ) + return internal_server_error(excep) + + # Create or update store + store, store_created = Store.objects.get_or_create(store_hash=store_hash) + store.access_token = access_token + store.scope = scope + store.save() + # If the app was installed before, make sure the old admin user is no longer marked + # as the admin + if not store_created: + old_store_admin_user = StoreAdminUser.objects.filter( + store__id=store.id, + is_admin=True + ).first() + if old_store_admin_user: + old_store_admin_user.is_admin = False + old_store_admin_user.save() + + # Create or update global BigCommerce store admin user + admin_user, __ = AdminUser.objects.get_or_create(bc_id=bc_store_admin_user_id) + admin_user.bc_email = bc_store_admin_email + admin_user.save() + + store_admin_user, store_admin_user_created = \ + StoreAdminUser.objects.get_or_create(store_id=store.id, bc_admin_user_id=admin_user.id) + if store_admin_user_created: + store_admin_user.is_admin = True + store_admin_user.save() + + response = redirect( + reverse("bigcommerce_app_single_click:index") + + f'?bc_storeadminuserid={store_admin_user.bc_admin_user.bc_id}' + ) + + # Todo: This doesn't work at the moment. + # response.set_cookie( + # 'bc_storeadminuserid', store_admin_user.bc_admin_user.bc_id, secure=True, max_age=1000 + # ) + + return response + + @classmethod + def load(cls, request): + """ + BigCommerce sends a GET request to your app’s load URL when the store owner or user clicks + to load the app. + + The steps to handle this callback are as follows: + + 1. Verify the signed payload(https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#verifying-the-signed-payload). # lint-amnesty, pylint: disable=line-too-long + 2. Identify the user (https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#identifying-users). # lint-amnesty, pylint: disable=line-too-long + 3. Respond with HTML for the control panel iFrame. + + https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#load-callback + + Todo: Need to implement this for BigCommerce admin interface. + """ + LOGGER.info( + "Initiated single-click app `load_callback` from BigCommerce" + ) + + # Decode and verify payload + payload = request.GET.get('signed_payload_jwt') + try: + user_data = BigcommerceApi.oauth_verify_payload_jwt( + payload, + client_secret(), + client_id() + ) + bc_store_admin_user_id = user_data['user']['id'] + bc_store_admin_email = user_data['user']['email'] + store_hash = user_data['sub'].split('stores/')[1] + except Exception as excep: # lint-amnesty, pylint: disable=broad-except + LOGGER.error( + "Could not get access token from BigCommerce in `auth_callback`%s", + excep + ) + return internal_server_error(excep) + + # Lookup store + store = Store.objects.filter(store_hash=store_hash).first() + if store is None: + return HttpResponse("Store not found!", status=401) + + # Lookup user and create if doesn't exist (this can happen if you enable multi-user + # when registering your app) + + # Create or update global BigCommerce store admin user + admin_user, __ = AdminUser.objects.get_or_create(bc_id=bc_store_admin_user_id) + admin_user.bc_email = bc_store_admin_email + admin_user.save() + + store_admin_user, __ = \ + StoreAdminUser.objects.get_or_create(store_id=store.id, bc_admin_user_id=admin_user.id) + store_admin_user.save() + + response = redirect( + reverse("bigcommerce_app_single_click:index") + + f'?bc_storeadminuserid={store_admin_user.bc_admin_user.bc_id}' + ) + + # Todo: This doesn't work at the moment. + # response.set_cookie( + # 'bc_storeadminuserid', store_admin_user.bc_admin_user.bc_id, secure=True, max_age=1000 + # ) + + return response + + @classmethod + def uninstall(cls, request): + """ + BigCommerce sends a GET request to your app’s uninstall URL when the store owner clicks + to uninstall the app. + + The steps to handle this callback are as follows: + + 1. Verify the signed payload (https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#verifying-the-signed-payload). # lint-amnesty, pylint: disable=line-too-long + 2. Identify the user (https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#identifying-users). # lint-amnesty, pylint: disable=line-too-long + 3. Remove the user’s data from your app’s database. + + https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#uninstall-callback + + Todo: Need to implement this for BigCommerce admin interface. + """ + LOGGER.info( + "Initiated single-click app `uninstall_callback` from BigCommerce" + ) + + # Decode and verify payload + payload = request.GET.get('signed_payload_jwt') + try: + user_data = BigcommerceApi.oauth_verify_payload_jwt( + payload, + client_secret(), + client_id() + ) + store_hash = user_data['sub'].split('stores/')[1] + except Exception as excep: # lint-amnesty, pylint: disable=broad-except + LOGGER.error( + "Could not get access token from BigCommerce in `auth_callback`\n%s", + excep + ) + return internal_server_error(excep) + + # Lookup store + store = Store.objects.filter(store_hash=store_hash).first() + if store is None: + return HttpResponse("Store not found!", status=401) + + # Clean up: delete store associated users. This logic is up to you. + # You may decide to keep these records around in case the user installs + # your app again. + store_admin_users = StoreAdminUser.objects.filter(store__id=store.id) + for store_admin_user in store_admin_users: + store_admin_user.delete() + + return HttpResponse("Deleted", status=204) + + @classmethod + def remove_user(cls, request): + """ + BigCommerce sends a GET request to your app’s remove user callback when a store admin + revokes a user’s access to the app. + + The steps to handle this callback are as follows: + + 1. Verify the signed payload (https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#verifying-the-signed-payload). # lint-amnesty, pylint: disable=line-too-long + 2. Identify the user (https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#identifying-users). # lint-amnesty, pylint: disable=line-too-long + 3. Remove the user’s data from your app’s database. + + https://developer.bigcommerce.com/api-docs/apps/guide/callbacks#remove-user-callback + + Todo: Need to implement this for BigCommerce admin interface. + """ + LOGGER.info( + "Initiated single-click app `remove_user_callback` from BigCommerce" + ) + + # Decode and verify payload + payload = request.GET.get('signed_payload_jwt') + try: + user_data = BigcommerceApi.oauth_verify_payload_jwt( + payload, + client_secret(), + client_id() + ) + bc_store_admin_user_id = user_data['user']['id'] + store_hash = user_data['sub'].split('stores/')[1] + except Exception as excep: # lint-amnesty, pylint: disable=broad-except + LOGGER.error( + "Could not get access token from BigCommerce in `auth_callback`%s", + excep + ) + return internal_server_error(excep) + + # Lookup store + store = Store.objects.filter(store_hash=store_hash).first() + if store is None: + return HttpResponse("Store not found!", status=401) + + # Lookup user and delete it + admin_user = AdminUser.objects.filter(bc_id=bc_store_admin_user_id) + if admin_user is not None: + store_admin_user = StoreAdminUser.objects.filter( + bc_admin_user__id=bc_store_admin_user_id, + store__id=store.id + ).first() + store_admin_user.delete() + + return HttpResponse("Deleted", status=204) diff --git a/lms/djangoapps/bigcommerce_app/events/__init__.py b/lms/djangoapps/bigcommerce_app/events/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/bigcommerce_app/events/course_enrollment.py b/lms/djangoapps/bigcommerce_app/events/course_enrollment.py new file mode 100644 index 000000000000..2a04184ae8c4 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/events/course_enrollment.py @@ -0,0 +1,14 @@ +""" +Events related to how BigCommerce Customers interface with the platform. +Todo: Not used at the moment because this is handled in the LMS dashboard page. +""" + +from lms.djangoapps.bigcommerce_app.utils import requires_bigcommerce_enabled + + +@requires_bigcommerce_enabled +def enroll_paid_bigcommerce_courses(user): # lint-amnesty, pylint: disable=unused-argument + """ + Enrolls BigCommerce Customers into platform courses based on products + (e.g. BigCommerce Courses) paid for. + """ diff --git a/lms/djangoapps/bigcommerce_app/handlers.py b/lms/djangoapps/bigcommerce_app/handlers.py new file mode 100644 index 000000000000..72a037d2e908 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/handlers.py @@ -0,0 +1,29 @@ +""" +BigCommerce related signal handlers. +Todo: This is not currently used since we handle this directly in the LMS Dashboard page. +""" + +from django.contrib.auth.signals import user_logged_in +from django.dispatch import receiver + +from lms.djangoapps.bigcommerce_app.events.course_enrollment import enroll_paid_bigcommerce_courses +from lms.djangoapps.bigcommerce_app.utils import bigcommerce_enabled +# from lms.djangoapps.bigcommerce_app.models import Customer, StoreCustomer + + +@receiver(user_logged_in) +def enroll_courses_on_login(sender, event=None, user=None, **kwargs): # pylint: disable=unused-argument + """ + Registers platform users to paid BigCommerce products (e.g. Courses). + """ + if bigcommerce_enabled(): + enroll_paid_bigcommerce_courses(user) + + +@receiver(user_logged_in) +def store_customer_information(sender, event=None, user=None, **kwargs): # pylint: disable=unused-argument + """ + Save the current logged in user for API calls to BigCommerce. + + Note: Couldn't find a way to find Django logged in user easily. + """ diff --git a/lms/djangoapps/bigcommerce_app/migrations/0001_initial.py b/lms/djangoapps/bigcommerce_app/migrations/0001_initial.py new file mode 100644 index 000000000000..d14eba70e7cc --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0001_initial.py @@ -0,0 +1,67 @@ +# Generated by Django 2.2.15 on 2021-10-15 17:30 + +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='AdminUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bc_id', models.IntegerField()), + ('bc_email', models.EmailField(max_length=255, verbose_name='email address')), + ], + ), + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bc_id', models.IntegerField()), + ('bc_email', models.EmailField(max_length=255, verbose_name='email address')), + ], + ), + migrations.CreateModel( + name='Store', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('store_hash', models.CharField(max_length=16, unique=True)), + ('access_token', models.CharField(max_length=128)), + ('scope', models.TextField()), + ], + ), + migrations.CreateModel( + name='StoreCustomer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bc_customer_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bigcommerce_app.Customer')), + ('store_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bigcommerce_app.Store')), + ], + ), + migrations.CreateModel( + name='StoreCustomerPlatformUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bc_store_customer_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bigcommerce_app.StoreCustomer')), + ('platform_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='StoreAdminUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('admin', models.BooleanField(default=False)), + ('bc_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bigcommerce_app.AdminUser')), + ('store_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bigcommerce_app.Store')), + ], + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0002_auto_20211015_1907.py b/lms/djangoapps/bigcommerce_app/migrations/0002_auto_20211015_1907.py new file mode 100644 index 000000000000..241bdec23c73 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0002_auto_20211015_1907.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.15 on 2021-10-15 19:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='storeadminuser', + old_name='bc_user', + new_name='bc_admin_user_id', + ), + migrations.RenameField( + model_name='storeadminuser', + old_name='admin', + new_name='is_admin', + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0003_auto_20211015_1936.py b/lms/djangoapps/bigcommerce_app/migrations/0003_auto_20211015_1936.py new file mode 100644 index 000000000000..1c18b32ea8c8 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0003_auto_20211015_1936.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.15 on 2021-10-15 19:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0002_auto_20211015_1907'), + ] + + operations = [ + migrations.RenameField( + model_name='storeadminuser', + old_name='bc_admin_user_id', + new_name='bc_admin_user', + ), + migrations.RenameField( + model_name='storeadminuser', + old_name='store_id', + new_name='store', + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0004_auto_20211015_1946.py b/lms/djangoapps/bigcommerce_app/migrations/0004_auto_20211015_1946.py new file mode 100644 index 000000000000..56f322cb2ef3 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0004_auto_20211015_1946.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.15 on 2021-10-15 19:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0003_auto_20211015_1936'), + ] + + operations = [ + migrations.RenameField( + model_name='storecustomer', + old_name='bc_customer_id', + new_name='bc_customer', + ), + migrations.RenameField( + model_name='storecustomer', + old_name='store_id', + new_name='store', + ), + migrations.RenameField( + model_name='storecustomerplatformuser', + old_name='bc_store_customer_id', + new_name='bc_store_customer', + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0005_auto_20211019_0209.py b/lms/djangoapps/bigcommerce_app/migrations/0005_auto_20211019_0209.py new file mode 100644 index 000000000000..1e0ef5d7e5f3 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0005_auto_20211019_0209.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.15 on 2021-10-19 02:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0004_auto_20211015_1946'), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='bc_group_id', + field=models.IntegerField(default=0), + preserve_default=False, + ), + migrations.AlterField( + model_name='storeadminuser', + name='store', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='bigcommerce_app.Store'), + ), + migrations.AlterField( + model_name='storecustomer', + name='store', + field=models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='bigcommerce_app.Store'), + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0006_auto_20211021_1916.py b/lms/djangoapps/bigcommerce_app/migrations/0006_auto_20211021_1916.py new file mode 100644 index 000000000000..356bc7e70b48 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0006_auto_20211021_1916.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.15 on 2021-10-21 19:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0005_auto_20211019_0209'), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='bc_first_name', + field=models.TextField(default=''), + preserve_default=False, + ), + migrations.AddField( + model_name='customer', + name='bc_last_name', + field=models.TextField(default=''), + preserve_default=False, + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0007_auto_20211021_2015.py b/lms/djangoapps/bigcommerce_app/migrations/0007_auto_20211021_2015.py new file mode 100644 index 000000000000..26b9002d5526 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0007_auto_20211021_2015.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.15 on 2021-10-21 20:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0006_auto_20211021_1916'), + ] + + operations = [ + migrations.AlterField( + model_name='customer', + name='bc_first_name', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='customer', + name='bc_group_id', + field=models.IntegerField(blank=True), + ), + migrations.AlterField( + model_name='customer', + name='bc_last_name', + field=models.TextField(blank=True), + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0008_auto_20211021_2019.py b/lms/djangoapps/bigcommerce_app/migrations/0008_auto_20211021_2019.py new file mode 100644 index 000000000000..b08fc32b9f95 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0008_auto_20211021_2019.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.15 on 2021-10-21 20:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0007_auto_20211021_2015'), + ] + + operations = [ + migrations.AlterField( + model_name='customer', + name='bc_group_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/__init__.py b/lms/djangoapps/bigcommerce_app/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/bigcommerce_app/models.py b/lms/djangoapps/bigcommerce_app/models.py new file mode 100644 index 000000000000..e2be0346d8e2 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/models.py @@ -0,0 +1,137 @@ +""" +Model class for BigCommerce. +""" + +import logging + +from django.db import models +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user + + +LOGGER = logging.getLogger(__name__) + + +class Store(models.Model): + """ + Specifies a BigCommerce store. + """ + store_hash = models.CharField(max_length=16, blank=False, unique=True) + access_token = models.CharField(max_length=128, blank=False) + scope = models.TextField() + + def __str__(self): + return f"Hash: {self.store_hash}\nScope: {self.scope}\nAccess Token: {self.access_token}" + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + app_label = "bigcommerce_app" + + +class AdminUser(models.Model): + """ + Specifies a BigCommerce store user. + """ + bc_id = models.IntegerField(blank=False) + bc_email = models.EmailField( + verbose_name='email address', + max_length=255, + unique=False, + ) + + def __str__(self): + return f"Id: {self.bc_id}\nEmail: {self.bc_email}" + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + app_label = "bigcommerce_app" + + +class StoreAdminUser(models.Model): + """ + Specifies a BigCommerce store mapping with the BigCommerce admin user account. + """ + store = models.ForeignKey(Store, on_delete=models.DO_NOTHING) + bc_admin_user = models.ForeignKey(AdminUser, on_delete=models.CASCADE) + is_admin = models.BooleanField(blank=False, default=False) + + def __str__(self): + return f"Store: {self.store}\nAdmin User: {self.bc_admin_user}\nIs Admin: {self.is_admin}" + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + app_label = "bigcommerce_app" + + +class Customer(models.Model): + """ + Specifies a BigCommerce store user. + """ + bc_id = models.IntegerField(blank=False) + bc_email = models.EmailField( + verbose_name='email address', + max_length=255, + unique=False, + ) + bc_group_id = models.IntegerField(blank=True, null=True) + bc_first_name = models.TextField(blank=True) + bc_last_name = models.TextField(blank=True) + + def __str__(self): + return f"Id: {self.bc_id}\nEmail: {self.bc_email}\nGroup: {self.bc_group_id}\n" \ + "Full Name: {self.bc_first_name} {self.bc_last_name}" + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + app_label = "bigcommerce_app" + + +class StoreCustomer(models.Model): + """ + Specifies a BigCommerce store mapping with the BigCommerce customer account. + """ + store = models.ForeignKey(Store, on_delete=models.DO_NOTHING) + bc_customer = models.ForeignKey(Customer, on_delete=models.CASCADE) + + def __str__(self): + return f"Store: {self.store}\nCustomer: {self.bc_customer}" + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + app_label = "bigcommerce_app" + + +class StoreCustomerPlatformUser(models.Model): + """ + Specifies a BigCommerce store customer mapping with the platform user account. + """ + bc_store_customer = models.ForeignKey(StoreCustomer, on_delete=models.CASCADE) + platform_user = models.ForeignKey(User, on_delete=models.CASCADE) + + @classmethod + def locate_store_customer(cls, store_hash, platform_user): + """ + Returns a BigCommerce user for a store. + """ + + platform_user_store_customers = cls.objects.filter(platform_user=platform_user) + + if store_hash: + bc_site_store = Store.objects.get(store_hash=store_hash) + + if bc_site_store: + for platform_store_customer in platform_user_store_customers: + if platform_store_customer.bc_store_customer.store.store_hash == \ + bc_site_store.store_hash: + LOGGER.info( + "Located %s from %s for %s platform account", + platform_store_customer.bc_store_customer.bc_customer, + store_hash, + platform_user + ) + return platform_store_customer.bc_store_customer.bc_customer.bc_id + + LOGGER.error( + "Could not locate BigCommerce %s Store Customer for %s platform account", + store_hash, + platform_user + ) + + return None + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + app_label = "bigcommerce_app" diff --git a/lms/djangoapps/bigcommerce_app/single_click/__init__.py b/lms/djangoapps/bigcommerce_app/single_click/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/bigcommerce_app/single_click/urls.py b/lms/djangoapps/bigcommerce_app/single_click/urls.py new file mode 100644 index 000000000000..3acb02de26be --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/single_click/urls.py @@ -0,0 +1,18 @@ +""" +BigCommerce Callback URLs for Single-Click app. +""" + +from django.urls import re_path + +from lms.djangoapps.bigcommerce_app.single_click import views + +APP_NAME = 'v1' +urlpatterns = [] + +urlpatterns += [ + re_path( + r'index/$', + views.single_click_index, + name='index' + ), +] diff --git a/lms/djangoapps/bigcommerce_app/single_click/views.py b/lms/djangoapps/bigcommerce_app/single_click/views.py new file mode 100644 index 000000000000..efe4045e53e8 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/single_click/views.py @@ -0,0 +1,64 @@ +""" +The main index page for the EducateWorkforce Single-Click application within BigCommerce app. +""" + +import logging + +from django.http import HttpResponse +from django.utils.translation import gettext as _ +from django.views.decorators.clickjacking import xframe_options_exempt +from common.djangoapps.edxmako.shortcuts import render_to_response +from lms.djangoapps.bigcommerce_app.models import StoreAdminUser +from lms.djangoapps.bigcommerce_app.utils import client_id + +from bigcommerce.api import BigcommerceApi + +LOGGER = logging.getLogger(__name__) +# _ = translation.ugettext + + +@xframe_options_exempt +def single_click_index(request): + """ + Provides the view for the BigCommerce index page. To be used from the callback + /auth and /load endpoints. + """ + LOGGER.info( + "Call `single_click_index` successfully." + ) + + # Lookup store + # Todo: This doesn't work at the moment. + # store_admin_user_id = request.COOKIES.get("bc_storeadminuserid", None) + store_admin_user_id = request.GET.get('bc_storeadminuserid', None) + if not store_admin_user_id: + return HttpResponse("Not logged in!", status=401) + + store_admin_user = StoreAdminUser.objects.filter( + bc_admin_user__bc_id=store_admin_user_id + ).first() + if store_admin_user is None: + return HttpResponse("Not logged in!", status=401) + store = store_admin_user.store + admin_user = store_admin_user.bc_admin_user + + # Construct api client for BigCommerce + client = BigcommerceApi(client_id=client_id(), + store_hash=store.store_hash, + access_token=store.access_token) + + # Fetch customers for the store. + customers = client.Customers.all() + + context = { + 'document_title': _('BigCommerce Single-Click App – EducateWorkforce'), + 'admin_user': admin_user, + 'store': store, + 'customers': customers, + # 'client_id': client_id() + } + + if request.method == 'GET': + return render_to_response("bigcommerce_app/index.html", context) + + return HttpResponse("Single Click App", status=301) diff --git a/lms/djangoapps/bigcommerce_app/tests/__init__.py b/lms/djangoapps/bigcommerce_app/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/bigcommerce_app/tests/factories.py b/lms/djangoapps/bigcommerce_app/tests/factories.py new file mode 100644 index 000000000000..08eb80c4d350 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/tests/factories.py @@ -0,0 +1,106 @@ +""" +Define factory classes to be used with the BigCommerce app testing. +""" + +import string + +from random import random, randrange + +import factory + + +from lms.djangoapps.bigcommerce_app.models import ( + Store, + AdminUser, + StoreAdminUser, + Customer, + StoreCustomer, + StoreCustomerPlatformUser +) +from common.djangoapps.student.tests.factories import UserFactory + + +class StoreFactory(factory.django.DjangoModelFactory): + """ + Factory for Store + """ + + class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring + model = Store + + store_hash = 'test_store_hash' + access_token = 'test_access_token' + scope = 'store_v2_customers_read_only store_v2_default store_v2_orders_read_only ' \ + 'store_v2_products_read_only users_basic_information' + + +class RandomStoreFactory(StoreFactory): + """ + Same as StoreClassFactory, but randomize the store_hash and access_token + """ + store_hash = factory.lazy_attribute(lambda _: str(random()).replace('.', '_')[2:18]) + access_token = factory.lazy_attribute( + lambda _: ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(31)) + ) + + +class AdminUserFactory(factory.django.DjangoModelFactory): + """ + Factory for AdminUser + """ + + class Meta(object): # lint-amnesty: pylint: disable=missing-class-docstring + model = AdminUser + + bc_id = factory.lazy_attribute(lambda _: randrange(0, 10000)) + bc_email = factory.Sequence('admin+user+bigcommerce+{}@gmail.com'.format) # lint-amnesty, pylint: disable=bad-option-value, consider-using-f-string + + +class StoreAdminUserFactory(factory.django.DjangoModelFactory): + """ + Factory for StoreAdminUser + """ + + class Meta(object): # lint-amnesty: pylint: disable=missing-class-docstring + model = StoreAdminUser + + store = factory.SubFactory(RandomStoreFactory) + bc_admin_user = factory.SubFactory(AdminUserFactory) + is_admin = False + + +class CustomerFactory(factory.django.DjangoModelFactory): + """ + Factory for Customer + """ + + class Meta(object): # lint-amnesty: pylint: disable=missing-class-docstring + model = Customer + + bc_id = factory.lazy_attribute(lambda _: randrange(0, 10000)) + bc_email = factory.Sequence('customer+bigcommerce+{}@edx.org'.format) # lint-amnesty, pylint: disable=bad-option-value, consider-using-f-string + bc_group_id = factory.lazy_attribute(lambda _: randrange(0, 10000)) + + +class StoreCustomerFactory(factory.django.DjangoModelFactory): + """ + Factory for StoreCustomer + """ + + class Meta(object): # lint-amnesty: pylint: disable=missing-class-docstring + model = StoreCustomer + + store = factory.SubFactory(RandomStoreFactory) + bc_customer = factory.SubFactory(CustomerFactory) + + +class StoreCustomerPlatformUserFactory(factory.django.DjangoModelFactory): + """ + Factory for StoreCustomerPlatformUser + """ + + class Meta(object): # lint-amnesty: pylint: disable=missing-class-docstring + model = StoreCustomerPlatformUser + + bc_store_customer = factory.SubFactory(StoreCustomerFactory) + platform_user = factory.SubFactory(UserFactory) diff --git a/lms/djangoapps/bigcommerce_app/tests/test_models.py b/lms/djangoapps/bigcommerce_app/tests/test_models.py new file mode 100644 index 000000000000..580d9028964b --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/tests/test_models.py @@ -0,0 +1,171 @@ +""" +Tests for integration with BigCommerce ecommerce service. +""" + +from django.test import TestCase +from django.test.utils import override_settings + +from lms.djangoapps.bigcommerce_app.models import ( + Store, + # AdminUser, + # StoreAdminUser, + Customer, + StoreCustomer, + StoreCustomerPlatformUser +) + +from lms.djangoapps.bigcommerce_app.tests.factories import ( + StoreFactory, + # RandomStoreFactory, + CustomerFactory, + StoreCustomerFactory, + StoreCustomerPlatformUserFactory +) + +from common.djangoapps.student.tests.factories import UserFactory + + +class StoreCustomerPlatformUserTest(TestCase): + """ + Test the validation features of StoreCustomerPlatformUser. + """ + + def setUp(self): # lint-amnesty, pylint: disable=invalid-name + """ + Setup defaults for BigCommerce store, customer, and platform accounts. + """ + + super().setUp() + + # Create BigCommerce store + self.store = StoreFactory.create( + store_hash='1nol3cto8', + access_token='lwqngxmw4d6yfv8un97ubujr8hc1hg1' + ) + + # Create BigCommerce customer account + self.customer = CustomerFactory.create(bc_email='john.doe@gmail.com') + + # Create platform user accounts + self.platform_user = UserFactory.create(email='john.doe@gmail.com') + + # Create a store customer mapping + self.store_customer = StoreCustomerFactory.create( + store=self.store, + bc_customer=self.customer + ) + + # Create store customer platform user mapping + self.store_customer_platform_user = StoreCustomerPlatformUserFactory( + bc_store_customer=self.store_customer, + platform_user=self.platform_user + ) + + def tearDown(self): # lint-amnesty, pylint: disable=invalid-name + """ + Remove test objects. + """ + + super().tearDown() + Store.objects.all().delete() + Customer.objects.all().delete() + StoreCustomer.objects.all().delete() + StoreCustomerPlatformUser.objects.all().delete() + + def test_locate_store_customer_exists(self): + """ + Verify that we can get a BigCommerce Store Customer from an existing platform User. + """ + + bc_store_customer_id = StoreCustomerPlatformUser.locate_store_customer( + store_hash=self.store.store_hash, + platform_user=self.platform_user + ) + self.assertEqual(bc_store_customer_id, self.customer.bc_id) + + def test_locate_store_customer_not_exists(self): + """ + Verify that we cannot get a BigCommerce Store Customer from an existing platform User. + """ + platform_user_not_found = UserFactory.create(email='jane.smith@gmail.com') + + bc_store_customer_id = StoreCustomerPlatformUser.locate_store_customer( + store_hash=self.store.store_hash, + platform_user=platform_user_not_found + ) + self.assertEqual(bc_store_customer_id, None) + + @override_settings(BIGCOMMERCE_APP_STORE_HASH='3r4l2hj8jc') + def test_locate_customer_multiple_stores(self): + """ + Verify that we cannot get a BigCommerce Store Customer from an existing platform User + registered for two separate BigCommerce storefronts. + """ + + # Create a separate BigCommerce store + new_store = StoreFactory.create( + store_hash='3r4l2hj8jc', + access_token='38b2kcz5salxe7pgw9zlyaj9b571yky' + ) + + # Create BigCommerce customer account with same email as another store. + new_customer_same_email = CustomerFactory.create(bc_email='john.doe@gmail.com') + + # Create a store customer mapping for new_store with similar customer email as the + # setUp store. + new_store_customer = StoreCustomerFactory.create( + store=new_store, + bc_customer=new_customer_same_email + ) + + # Create store customer platform user mapping for new_store, similar customer email, and + # same platform_user in setUp. + new_store_customer_platform_user = StoreCustomerPlatformUserFactory( # lint-amnesty, pylint: disable=unused-variable + bc_store_customer=new_store_customer, + platform_user=self.platform_user + ) + + # Check new store for this customer + bc_newstore_customer_id = StoreCustomerPlatformUser.locate_store_customer( + store_hash='3r4l2hj8jc', + platform_user=self.platform_user + ) + self.assertEqual(bc_newstore_customer_id, new_customer_same_email.bc_id) + + # Check setup store for this customer + bc_setupstore_customer_id = StoreCustomerPlatformUser.locate_store_customer( + store_hash=self.store.store_hash, + platform_user=self.platform_user + ) + self.assertNotEqual(bc_setupstore_customer_id, new_customer_same_email.bc_id) + + @override_settings(BIGCOMMERCE_APP_STORE_HASH='3r4l2hj8jc') + def test_locate_customer_multiple_stores_not_exists(self): + """ + Verify that we cannot get a BigCommerce Store Customer from an existing platform User + registered for two separate BigCommerce storefronts. + """ + + # Create a separate BigCommerce store + new_store = StoreFactory.create( + store_hash='3r4l2hj8jc', + access_token='38b2kcz5salxe7pgw9zlyaj9b571yky' + ) + + # Create BigCommerce customer account with same email as another store. + new_customer_same_email = CustomerFactory.create(bc_email='john.doe@gmail.com') + + # Create a store customer mapping for new_store with similar customer email as the + # setUp store. + new_store_customer = StoreCustomerFactory.create( # lint-amnesty, pylint: disable=unused-variable + store=new_store, + bc_customer=new_customer_same_email + ) + + # Don't setup a mapping for this platform user to BigCommerce storefront customer. + + bc_store_customer_id = StoreCustomerPlatformUser.locate_store_customer( + store_hash='3r4l2hj8jc', + platform_user=self.platform_user + ) + self.assertEqual(bc_store_customer_id, None) diff --git a/lms/djangoapps/bigcommerce_app/utils.py b/lms/djangoapps/bigcommerce_app/utils.py new file mode 100644 index 000000000000..d7e415eb34f6 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/utils.py @@ -0,0 +1,418 @@ +""" +Utility functions used by the bigcommerce_app. +""" + +import logging + +from django.conf import settings +from django.http import HttpResponse + +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.models import CourseEnrollment + +from openedx.core.djangolib.markup import HTML +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from lms.djangoapps.bigcommerce_app.models import ( + Store, + Customer, + StoreCustomer, + StoreCustomerPlatformUser +) + +import bigcommerce.api as bigcommerce_client +from bigcommerce.resources.products import ProductCustomFields + +LOGGER = logging.getLogger(__name__) + + +def requires_bigcommerce_enabled(function): + """ + Decorator that bails a function out early if bigcommerce isn't enabled. + """ + def wrapped(*args, **kwargs): + """ + Wrapped function which bails out early if bagdes aren't enabled. + """ + if not bigcommerce_enabled(): + return + return function(*args, **kwargs) + return wrapped + + +def bigcommerce_enabled(): + """ + returns a boolean indicating whether or not BigCommerce app is enabled. + """ + return configuration_helpers.get_value_for_org( + 'ENABLE_BIGCOMMERCE', + "SITE_NAME", + settings.FEATURES.get('ENABLE_BIGCOMMERCE', False) + ) + + +# +# Error handling and helpers +# +def _error_info(excep): + """ + Return HTML error message. + """ + content = "" + try: # it's probably a HttpException, if you're using the bigcommerce client + content += HTML("{}
{}
").format(str(excep.headers), str(excep.content)) + req = excep.response.request + content += HTML("
Request:
{}
{}
{}").format( + req.url, + str(req.headers), + str(req.body) + ) + except AttributeError as attr_excep: # not a HttpException + content += HTML("

(This page threw an exception: {})").format(str(attr_excep)) + return content + + +def internal_server_error(excep): + """ + Return internal error message. + """ + content = HTML("Internal Server Error: {}
").format(str(excep)) + content += _error_info(excep) + return HttpResponse(content, status=500) + + +def _enabled_current_site_provider(): + """ + Helper method to return current provider for the current site. + """ + + # Importing these applications here prevents this error. + # cannot import name 'EmailProviderConfig' from partially initialized module + # 'common.djangoapps.third_party_auth.models' (most likely due to a circular import) + from common.djangoapps import third_party_auth # lint-amnesty, pylint: disable=import-outside-toplevel + from common.djangoapps.third_party_auth.models import EmailProviderConfig, _PSA_EMAIL_BACKENDS # lint-amnesty, pylint: disable=import-outside-toplevel + + if third_party_auth.is_enabled(): + email_backend_names = EmailProviderConfig.key_values('backend_name', flat=True) + for email_backend_name in email_backend_names: + provider = EmailProviderConfig.current(email_backend_name) + if provider.enabled_for_current_site and provider.backend_name in _PSA_EMAIL_BACKENDS: + return provider + + return None + + +def client_id(): + """ + Locate the client_id in the current enabled for site EmailProviderConfig. + """ + try: + return _enabled_current_site_provider().key + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not retrieve `client_id` from current site enabled" + "Third-party Auth Backend.\n%s", + excep + ) + + return "" + + +def client_secret(): + """ + Locate the client_secret in the current enabled for site EmailProviderConfig. + """ + try: + return _enabled_current_site_provider().secret + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not retrieve `client_secret` from current site enabled" + "Third-party Auth Backend.\n%s", + excep + ) + + return "" + + +def _store_hash(): + """ + Locate the store_hash in the current enabled for site EmailProviderConfig. + """ + try: + return _enabled_current_site_provider().get_setting("STOREFRONT").get("HASH") + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not retrieve `Storefront hash_code` from current site enabled" + "Third-party Auth Backend.\n%s", + excep + ) + + return "" + + +def store_hash(): + """ + Return BigCommerce store hash identifier. + """ + return _store_hash() + + +def access_token(): + """ + Return API access token for the BigCommerce store. + """ + return Store.objects.filter(store_hash=_store_hash()).first().access_token + + +def platform_lms_url(): + """ + Return the platform LMS root url. + """ + return configuration_helpers.get_value("LMS_ROOT_URL", settings.LMS_ROOT_URL) + + +class BigCommerceAPI(): + """ + Handles talking with the BigCommerceAPI + """ + + def __init__(self): + self._api_client = bigcommerce_client.BigcommerceApi( + client_id=client_id(), + store_hash=store_hash(), + access_token=access_token() + ) + + @property + def api_client(self): + """ + Return the BigCommerce client used to make API calls. + """ + return self._api_client + + @classmethod + def bcapi_customer_metadata(cls, bc_customer_email): + """ + Returns Customer information from BigCommerce for current platform user. This is needed for + third-party auth to load full name registration form field. + + At the moment this calls V2 of the BigCommerce API which doesn't have an 'id' to make get + calls against. + https://developer.bigcommerce.com/api-reference/store-management/customers-v2/customers/getallcustomers # lint-amnesty, pylint: disable=line-too-long + """ + # cls._setup_api_client(cls) + bcapi_client = cls().api_client + + if bcapi_client: + try: + customer = bcapi_client.Customers.all(email=bc_customer_email)[0] + if customer: + + LOGGER.info( + "Successfully located BigCommerce %s Store Customer %s meta data", + store_hash(), + bc_customer_email + ) + + return customer + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not get access token from BigCommerce in `auth_callback`" + ) + return internal_server_error(excep) + + LOGGER.error( + "Could not locate BigCommerce %s Store Customer %s meta data", + store_hash(), + bc_customer_email + ) + return {} + + @classmethod + def bigcommerce_customer_save(cls, payload): + """ + Returns decode payload from JWT token passed in from BigCommerce third-party-auth complete + and saves the customer information on the platform. + """ + bcapi_client = cls().api_client + if bcapi_client: + try: + user_data = bcapi_client.oauth_verify_payload_jwt( + payload, + client_secret(), + client_id() + ) + + bc_customer = cls.bcapi_customer_metadata(user_data['customer']['email']) + + # Save the BigCommerce Customer for the platform + try: + new_customer, __ = Customer.objects.get_or_create( + bc_id=bc_customer.id, + bc_email=bc_customer.email + ) + new_customer.bc_group_id = bc_customer.customer_group_id + new_customer.bc_first_name = bc_customer.first_name + new_customer.bc_last_name = bc_customer.last_name + new_customer.save() + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could save BigCommerce Customer %s\n%s", + bc_customer.email, + excep + ) + return {} + + # Save the BigCommerce StoreCustomer fro the platform + store = Store.objects.filter(store_hash=store_hash()).first() + if store: + try: + new_store_customer, __ = StoreCustomer.objects.get_or_create( + store=store, + bc_customer=new_customer + ) + new_store_customer.save() + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could save BigCommerce StoreCustomer %s – %s\n%s", + store_hash(), + bc_customer.email, + excep + ) + return {} + else: + LOGGER.error( + "Could not create StoreCustomer for %s – %s.", + store_hash(), + new_customer.email + ) + + return { + 'store_hash': store_hash(), + 'id': new_customer.bc_id, + 'email': new_customer.bc_email, + 'group_id': new_customer.bc_group_id, + 'first_name': new_customer.bc_first_name, + 'last_name': new_customer.bc_last_name + } + + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not decode JWT payload from BigCommerce Customer." + ) + return internal_server_error(excep) + + return {} + + @classmethod + def bigcommerce_store_customer_platform_user_save(cls, payload): + """ + Returns true whether or not the StoreCustomerPlatformUser was saved successfully. + """ + try: + platform_user = payload.get('platform_user') + + store = Store.objects.filter(store_hash=store_hash()).first() + if store: + try: + bc_store_customer = StoreCustomer.objects.filter( + store=store, + bc_customer__bc_id=payload.get('bc_uid') + ).first() + + bc_store_customer_platform_user, bc_store_customer_platform_user_created = \ + StoreCustomerPlatformUser.objects.get_or_create( + bc_store_customer=bc_store_customer, + platform_user=platform_user + ) + bc_store_customer_platform_user.save() + + if bc_store_customer_platform_user_created: + return True + + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could save StoreCustomerPlatformUser: BigCommerce %s – Platform %s", + payload.get('bc_uid'), + platform_user.id + ) + return internal_server_error(excep) + else: + LOGGER.error( + "Could not locate %s to make StoreCustomerPlatformUser mapping.", + store_hash() + ) + + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not find BigCommerce StoreCustomer." + ) + return internal_server_error(excep) + + return False + + @classmethod + def get_order_items(cls, customer_id): + """ + Locate BigCommerce orders for the customer. + """ + + bcapi_client = cls().api_client + + if bcapi_client: # lint-amnesty, pylint: disable=too-many-nested-blocks + courses = [] + + try: + orders = bcapi_client.Orders.all(customer_id=customer_id) + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not find BigCommerce Orders for %s\n%s", + customer_id, + excep + ) + return courses + + try: + for order in orders: + products = bcapi_client.OrderProducts.all(order.id) + + for product in products: + product_details = bcapi_client.Products.get(product.product_id) + custom_fields = product_details.custom_fields() + + if custom_fields: + for field in custom_fields: + if isinstance(field, ProductCustomFields) and \ + field.name == 'Course ID': + courses.append(field.text) + except AttributeError as excep: + LOGGER.error( + "Could not find BigCommerce orders for the customer.\n%s", + excep + ) + return courses + + return courses + + @classmethod + def get_bc_course_enrollments(cls, user): + """ + Enroll BigCommerce customer in LMS courses from purchased BigCommerce course orders. + """ + + try: + bc_customer_id = StoreCustomerPlatformUser.locate_store_customer(store_hash(), user.id) + except Exception as excep: # pylint: disable=broad-except + LOGGER.error( + "Could not find BigCommerce customer.\n%s", + excep + ) + return + + if bc_customer_id: + enroll_courses = cls.get_order_items(bc_customer_id) + + if enroll_courses: + for course_key in enroll_courses: + course_key = CourseKey.from_string(course_key) + CourseEnrollment.enroll(user, course_key) diff --git a/lms/envs/common.py b/lms/envs/common.py index acd86c3e90eb..79fd8583c2af 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -695,6 +695,9 @@ # .. toggle_tickets: https://openedx.atlassian.net/browse/SOL-1325 'ENABLE_OPENBADGES': False, + # Enable BigCommerce Integration + 'ENABLE_BIGCOMMERCE': False, + # .. toggle_name: FEATURES['ENABLE_LTI_PROVIDER'] # .. toggle_implementation: DjangoSetting # .. toggle_default: False @@ -985,6 +988,12 @@ # .. toggle_warnings: For consistency in user-experience, keep the value in sync with the setting of the same name # in the LMS and CMS. 'MARK_LIBRARY_CONTENT_BLOCK_COMPLETE_ON_VIEW': False, + + # Whether to display the account linked accounts view. + 'ENABLE_ACCOUNT_LINKED_ACCOUNTS': True, + + # Whether to display the account order history view. + 'ENABLE_ACCOUNT_ORDER_HISTORY': True, } # Specifies extra XBlock fields that should available when requested via the Course Blocks API @@ -3228,6 +3237,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # Blockstore 'blockstore.apps.bundles', + + # BigCommerce App + 'lms.djangoapps.bigcommerce_app', ] ######################### CSRF ######################################### @@ -3601,6 +3613,32 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # .. toggle_warnings: Review FEATURES['ENABLE_OPENBADGES'] for further context. BADGR_ENABLE_NOTIFICATIONS = False +#################### BigCommerce Settings ####################### + +BIGCOMMERCE_APP_CLIENT_ID = None +BIGCOMMERCE_APP_CLIENT_SECRET = None +BIGCOMMERCE_APP_STORE_HASH = None +BIGCOMMERCE_APP_STORE_URL = None + +#################### Qualtrics Settings ####################### +QUALTRICS_API_BASE_URL = None +QUALTRICS_BACKEND = 'qualtrics.backends.qualtrics.qualtricsBackend' +QUALTRICS_API_VERSION = "v3" +QUALTRICS_API_TOKEN_EXPIRATION = 3599 # 1 hr +QUALTRICS_API_TOKEN_CACHE = 'qualtrics_api_token_cache' + +# OAuth2 Authentication (Client Credentials) +# https://api.qualtrics.com/instructions/docs/Instructions/oauth-authentication.md +# Client credentials grant type doesn't use `refresh` token for access. +QUALTRICS_API_CLIENT_ID = None +QUALTRICS_API_CLIENT_SECRET = None + +# API Token Authentication +# https://api.qualtrics.com/instructions/docs/Instructions/api-key-authentication.md +# Recommend OAuth2 Authentication to limit scope of API calls. +# This is automatically switch over to OAuth2 after API v1. +QUALTRICS_API_TOKEN = None + ###################### Grade Downloads ###################### # These keys are used for all of our asynchronous downloadable files, including # the ones that contain information other than grades. diff --git a/lms/envs/test.py b/lms/envs/test.py index b50f3e5e8a05..8a02011378f8 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -182,6 +182,7 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': 'edxapp', 'ATOMIC_REQUESTS': True, }, 'student_module_history': { @@ -636,3 +637,20 @@ #################### Network configuration #################### # Tests are not behind any proxies CLOSEST_CLIENT_IP_FROM_HEADERS = [] + +#################### BigCommerce Settings ####################### + +FEATURES.update({ + + # Enable BigCommerce feature. + 'ENABLE_BIGCOMMERCE': True, + +}) + +if FEATURES.get('ENABLE_BIGCOMMERCE'): + BIGCOMMERCE_APP_CLIENT_ID = "6ms4rvrkhnv5m3h1o582mtqb7wzixyr" + BIGCOMMERCE_APP_CLIENT_SECRET = "385d434a82fe40cd838ad5891bdbc1548209547112f1e81c4b16bc2842d1a329" + BIGCOMMERCE_APP_STORE_HASH = "1nol3cto8" + BIGCOMMERCE_APP_STORE_URL = "https://educateworkforce-development.mybigcommerce.com" + + INSTALLED_APPS.append('bigcommerce') diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js index 882f3f693d7b..b45a204bbfa9 100644 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ b/lms/static/js/student_account/views/account_settings_factory.js @@ -12,6 +12,7 @@ AccountSettingsFieldViews, AccountSettingsView, StringUtils, HtmlUtils) { return function( fieldsData, + disableLinkedAccountsTab, disableOrderHistoryTab, ordersHistoryData, authData, @@ -369,29 +370,32 @@ countryDropdownField = getUserField(userFields, 'country'); timeZoneDropdownField.listenToCountryView(countryDropdownField); - accountsSectionData = [ - { - title: gettext('Linked Accounts'), - subtitle: StringUtils.interpolate( - gettext('You can link your social media accounts to simplify signing in to {platform_name}.'), - {platform_name: platformName} - ), - fields: _.map(authData.providers, function(provider) { - return { - view: new AccountSettingsFieldViews.AuthFieldView({ - title: provider.name, - valueAttribute: 'auth-' + provider.id, - helpMessage: '', - connected: provider.connected, - connectUrl: provider.connect_url, - acceptsLogins: provider.accepts_logins, - disconnectUrl: provider.disconnect_url, - platformName: platformName - }) - }; - }) - } - ]; + accountsSectionData = [] + if ( !disableLinkedAccountsTab ) { + accountsSectionData = [ + { + title: gettext('Linked Accounts'), + subtitle: StringUtils.interpolate( + gettext('You can link your social media accounts to simplify signing in to {platform_name}.'), + {platform_name: platformName} + ), + fields: _.map(authData.providers, function(provider) { + return { + view: new AccountSettingsFieldViews.AuthFieldView({ + title: provider.name, + valueAttribute: 'auth-' + provider.id, + helpMessage: '', + connected: provider.connected, + connectUrl: provider.connect_url, + acceptsLogins: provider.accepts_logins, + disconnectUrl: provider.disconnect_url, + platformName: platformName + }) + }; + }) + } + ]; + } ordersHistoryData.unshift( { @@ -402,31 +406,34 @@ } ); - ordersSectionData = [ - { - title: gettext('My Orders'), - subtitle: StringUtils.interpolate( - gettext('This page contains information about orders that you have placed with {platform_name}.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - fields: _.map(ordersHistoryData, function(order) { - orderNumber = order.number; - if (orderNumber === 'ORDER NUMBER') { - orderNumber = 'orderId'; - } - return { - view: new AccountSettingsFieldViews.OrderHistoryFieldView({ - totalPrice: order.price, - orderId: order.number, - orderDate: order.order_date, - receiptUrl: order.receipt_url, - valueAttribute: 'order-' + orderNumber, - lines: order.lines - }) - }; - }) - } - ]; + ordersSectionData = [] + if ( !disableOrderHistoryTab ) { + ordersSectionData = [ + { + title: gettext('My Orders'), + subtitle: StringUtils.interpolate( + gettext('This page contains information about orders that you have placed with {platform_name}.'), // eslint-disable-line max-len + {platform_name: platformName} + ), + fields: _.map(ordersHistoryData, function(order) { + orderNumber = order.number; + if (orderNumber === 'ORDER NUMBER') { + orderNumber = 'orderId'; + } + return { + view: new AccountSettingsFieldViews.OrderHistoryFieldView({ + totalPrice: order.price, + orderId: order.number, + orderDate: order.order_date, + receiptUrl: order.receipt_url, + valueAttribute: 'order-' + orderNumber, + lines: order.lines + }) + }; + }) + } + ]; + } accountSettingsView = new AccountSettingsView({ model: userAccountModel, @@ -438,6 +445,7 @@ ordersTabSections: ordersSectionData }, userPreferencesModel: userPreferencesModel, + disableLinkedAccountsTab: disableLinkedAccountsTab, disableOrderHistoryTab: disableOrderHistoryTab, betaLanguage: betaLanguage }); diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js index fa50a031678b..1378ec5e7ab4 100644 --- a/lms/static/js/student_account/views/account_settings_view.js +++ b/lms/static/js/student_account/views/account_settings_view.js @@ -36,16 +36,18 @@ tabindex: 0, selected: true, expanded: true - }, - { + } + ]; + if (!view.options.disableLinkedAccountsTab) { + accountSettingsTabs.push({ name: 'accountsTabSections', id: 'accounts-tab', label: gettext('Linked Accounts'), tabindex: -1, selected: false, expanded: false - } - ]; + }); + } if (!view.options.disableOrderHistoryTab) { accountSettingsTabs.push({ name: 'ordersTabSections', diff --git a/lms/templates/bigcommerce_app/index.html b/lms/templates/bigcommerce_app/index.html new file mode 100644 index 000000000000..efbfc98266f0 --- /dev/null +++ b/lms/templates/bigcommerce_app/index.html @@ -0,0 +1,6 @@ +<%page expression_filter="h"/> +<%inherit file="single-click-app-base.html" /> + +
+

This application is not currently used on the BigCommerce store. Here are some configuration values that are utilized on this external application.

+
diff --git a/lms/templates/bigcommerce_app/single-click-app-base.html b/lms/templates/bigcommerce_app/single-click-app-base.html new file mode 100644 index 000000000000..d846eda67242 --- /dev/null +++ b/lms/templates/bigcommerce_app/single-click-app-base.html @@ -0,0 +1,56 @@ +<%page expression_filter="h"/> +<%namespace name='static' file='../static_content.html'/> +<% +# set doc language direction +from django.utils.translation import get_language_bidi +from django.utils.translation import ugettext as _ + +from openedx.core.djangolib.markup import Text, HTML +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string + +%> + + + + + + + + + ${document_title} + + + + +
+ +

EducateWorkforce Single-Click App Configuration

+ +
+ + ${self.body()} + +
+ +

Store

+
${store }
+ +
+

Store Admin User

+
${admin_user }
+ +
+ +

Store Customers

+ +
+ + diff --git a/lms/templates/student_account/account_settings.html b/lms/templates/student_account/account_settings.html index 9f43b0b60156..3a206a634121 100644 --- a/lms/templates/student_account/account_settings.html +++ b/lms/templates/student_account/account_settings.html @@ -52,6 +52,7 @@ AccountSettingsFactory( fieldsData, + ${ disable_linked_accounts_tab | n, dump_js_escaped_json}, ${ disable_order_history_tab | n, dump_js_escaped_json }, ordersHistoryData, authData, diff --git a/lms/urls.py b/lms/urls.py index 11e6913975ae..34f34831172f 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -224,6 +224,27 @@ path('api/badges/v1/', include(('lms.djangoapps.badges.api.urls', 'badges'), namespace='badges_api')), ] +if settings.FEATURES.get('ENABLE_BIGCOMMERCE'): + urlpatterns += [ + # Callback Endpoints + re_path( + r'^bigcommerce/callbacks/', + include( + ('lms.djangoapps.bigcommerce_app.callbacks.urls', 'lms.djangoapps.bigcommerce_app'), namespace='bigcommerce_app_callbacks' + ) + ), + ] + urlpatterns += [ + # Single-Click App Endpoints + re_path( + r'^bigcommerce/single-click/', + include( + ('lms.djangoapps.bigcommerce_app.single_click.urls', 'lms.djangoapps.bigcommerce_app'), + namespace='bigcommerce_app_single_click' + ) + ), + ] + urlpatterns += [ path('openassessment/fileupload/', include('openassessment.fileupload.urls')), ] diff --git a/openedx/core/djangoapps/user_api/accounts/settings_views.py b/openedx/core/djangoapps/user_api/accounts/settings_views.py index 4f835fbd38ce..6da55d066493 100644 --- a/openedx/core/djangoapps/user_api/accounts/settings_views.py +++ b/openedx/core/djangoapps/user_api/accounts/settings_views.py @@ -138,7 +138,16 @@ def account_settings_context(request): 'show_program_listing': ProgramsApiConfig.is_enabled(), 'show_dashboard_tabs': True, 'order_history': user_orders, - 'disable_order_history_tab': should_redirect_to_order_history_microfrontend(), + 'disable_linked_accounts_tab': not configuration_helpers.get_value( + 'ENABLE_ACCOUNT_LINKED_ACCOUNTS', + settings.FEATURES.get('ENABLE_ACCOUNT_LINKED_ACCOUNTS', False) + ), + 'disable_order_history_tab': + should_redirect_to_order_history_microfrontend() + or not configuration_helpers.get_value( + 'ENABLE_ACCOUNT_ORDER_HISTORY', + settings.FEATURES.get('ENABLE_ACCOUNT_ORDER_HISTORY', False) + ), 'enable_account_deletion': configuration_helpers.get_value( 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) ), diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index 6e6247016b72..0acb09682c91 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -20,6 +20,7 @@ from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter from openedx.core.djangoapps.oauth_dispatch.api import create_dot_access_token from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_from_token +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.accounts.utils import retrieve_last_sitewide_block_completed from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError from common.djangoapps.util.json_request import JsonResponse @@ -78,7 +79,7 @@ def delete_logged_in_cookies(response): response.delete_cookie( cookie_name, path='/', - domain=settings.SHARED_COOKIE_DOMAIN + domain=configuration_helpers.get_value("SHARED_COOKIE_DOMAIN", settings.SHARED_COOKIE_DOMAIN) ) return response @@ -88,7 +89,7 @@ def standard_cookie_settings(request): """ Returns the common cookie settings (e.g. expiration time). """ cookie_settings = { - 'domain': settings.SHARED_COOKIE_DOMAIN, + 'domain': configuration_helpers.get_value("SHARED_COOKIE_DOMAIN", settings.SHARED_COOKIE_DOMAIN), 'path': '/', 'httponly': None, } diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index c7868d552f47..4b97390aa0d1 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1647,3 +1647,9 @@ zipp==3.8.0 # The following packages are considered to be unsafe in a requirements file: # pip # setuptools + +# EducateWorkforce specific packages. This was manually added because `make upgrade` was causing a lot of packages to update. This limits the Python package changes for the release. +-e git+https://github.com/CUCWD/bigcommerce-api-python.git@bigcommerce-0.22.2-maple.1#egg=bigcommerce==0.22.2 + # via -r ./requirements/edx/private.in +-e git+https://github.com/CUCWD/social-auth-backend-bigcommerce.git@0.1.0-maple.1#egg=social-auth-backend-bigcommerce==0.1.0-maple.1 + # via -r ./requirements/edx/private.in diff --git a/requirements/edx/private.in b/requirements/edx/private.in new file mode 100644 index 000000000000..54f3d104fc48 --- /dev/null +++ b/requirements/edx/private.in @@ -0,0 +1,4 @@ +# BigCommerce +-e git+https://github.com/CUCWD/bigcommerce-api-python.git@bigcommerce-0.22.2-maple.1#egg=bigcommerce==0.22.2 +-e git+https://github.com/CUCWD/social-auth-backend-bigcommerce.git@0.1.0-maple.1#egg=social-auth-backend-bigcommerce==0.1.0-maple.1 + diff --git a/requirements/edx/private.txt b/requirements/edx/private.txt new file mode 100644 index 000000000000..787997d7324a --- /dev/null +++ b/requirements/edx/private.txt @@ -0,0 +1,51 @@ +# +# This file is autogenerated by pip-compile with python 3.8 +# To update, run: +# +# pip-compile ./requirements/edx/private.in +# +--index-url http://edx.devstack.devpi:3141/root/pypi/+simple/ +--extra-index-url https://pypi.python.org/simple +--trusted-host edx.devstack.devpi + +-e git+https://github.com/CUCWD/bigcommerce-api-python.git@bigcommerce-0.22.2-maple.1#egg=bigcommerce==0.22.2 + # via -r ./requirements/edx/private.in +-e git+https://github.com/CUCWD/social-auth-backend-bigcommerce.git@0.1.0-maple.1#egg=social-auth-backend-bigcommerce==0.1.0-maple.1 + # via -r ./requirements/edx/private.in +certifi==2021.10.8 + # via requests +cffi==1.15.0 + # via cryptography +charset-normalizer==2.0.7 + # via requests +cryptography==35.0.0 + # via social-auth-core +defusedxml==0.7.1 + # via + # python3-openid + # social-auth-core +idna==3.3 + # via requests +oauthlib==3.0.1 + # via + # requests-oauthlib + # social-auth-core +pycparser==2.20 + # via cffi +pyjwt==2.2.0 + # via + # bigcommerce + # social-auth-core +python3-openid==3.2.0 + # via social-auth-core +requests==2.26.0 + # via + # bigcommerce + # requests-oauthlib + # social-auth-core +requests-oauthlib==1.3.0 + # via social-auth-core +social-auth-core==4.1.0 + # via social-auth-backend-bigcommerce +urllib3==1.26.7 + # via requests diff --git a/requirements/edx/testing.in b/requirements/edx/testing.in index 7052fe67fcac..c4cc29ba6880 100644 --- a/requirements/edx/testing.in +++ b/requirements/edx/testing.in @@ -16,6 +16,7 @@ -r base.txt # Core edx-platform production dependencies -r coverage.txt # Utilities for calculating test coverage +-r private.txt # Include our EducateWorkforce specific packages beautifulsoup4 # Library for extracting data from HTML and XML files bok-choy # Framework for browser automation tests, based on selenium diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index e2dc35086707..4a79f1ef1116 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -1515,3 +1515,9 @@ zipp==3.8.0 # The following packages are considered to be unsafe in a requirements file: # setuptools + +# EducateWorkforce specific packages. This was manually added because `make upgrade` was causing a lot of packages to update. This limits the Python package changes for the release. +-e git+https://github.com/CUCWD/bigcommerce-api-python.git@bigcommerce-0.22.2-maple.1#egg=bigcommerce==0.22.2 + # via -r ./requirements/edx/private.in +-e git+https://github.com/CUCWD/social-auth-backend-bigcommerce.git@0.1.0-maple.1#egg=social-auth-backend-bigcommerce==0.1.0-maple.1 + # via -r ./requirements/edx/private.in From 48fd4cc2e6f29451f0fdc2049c83581ffad6cde9 Mon Sep 17 00:00:00 2001 From: Zachary Trabookis Date: Tue, 19 Apr 2022 11:30:28 -0400 Subject: [PATCH 2/5] Merge pull request #162 from CUCWD/feature.maple/ztraboo/feat-bigcommerce-merge-prior-release fix(bigcommerce): Update course enrollment check to include len. --- lms/djangoapps/bigcommerce_app/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/bigcommerce_app/utils.py b/lms/djangoapps/bigcommerce_app/utils.py index d7e415eb34f6..9ce091db5efd 100644 --- a/lms/djangoapps/bigcommerce_app/utils.py +++ b/lms/djangoapps/bigcommerce_app/utils.py @@ -412,7 +412,7 @@ def get_bc_course_enrollments(cls, user): if bc_customer_id: enroll_courses = cls.get_order_items(bc_customer_id) - if enroll_courses: + if len(enroll_courses) > 0: for course_key in enroll_courses: course_key = CourseKey.from_string(course_key) CourseEnrollment.enroll(user, course_key) From efa113efe29b26e03024e93313309073f8d4b5a0 Mon Sep 17 00:00:00 2001 From: Rebecca David Date: Wed, 3 Jan 2024 09:41:56 -0500 Subject: [PATCH 3/5] Merge pull request #180 from CUCWD/feature.maple/ztraboo/feat-merge-prior-release fix(third-party-auth): Need to account for additional required demographic fields for BigCommerce provider for `maple` release. --- .../migrations/0009_auto_20220823_2237.py | 23 ++++++++++ lms/djangoapps/bigcommerce_app/models.py | 4 +- .../djangoapps/user_authn/views/register.py | 29 +++++++++++++ .../user_authn/views/registration_form.py | 42 +++++++++++++++---- 4 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0009_auto_20220823_2237.py diff --git a/lms/djangoapps/bigcommerce_app/migrations/0009_auto_20220823_2237.py b/lms/djangoapps/bigcommerce_app/migrations/0009_auto_20220823_2237.py new file mode 100644 index 000000000000..a09b48e98831 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0009_auto_20220823_2237.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.13 on 2022-08-23 22:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0008_auto_20211021_2019'), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='bc_country_code', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='customer', + name='bc_postal_code', + field=models.TextField(blank=True), + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/models.py b/lms/djangoapps/bigcommerce_app/models.py index e2be0346d8e2..d4ec5def197c 100644 --- a/lms/djangoapps/bigcommerce_app/models.py +++ b/lms/djangoapps/bigcommerce_app/models.py @@ -75,7 +75,9 @@ class Customer(models.Model): def __str__(self): return f"Id: {self.bc_id}\nEmail: {self.bc_email}\nGroup: {self.bc_group_id}\n" \ - "Full Name: {self.bc_first_name} {self.bc_last_name}" + "Full Name: {self.bc_first_name} {self.bc_last_name}\n" \ + "Postal Code: {self.bc_postal_code}\n" \ + "Country Code: {self.bc.country_code}" class Meta(object): # lint-amnesty, pylint: disable=missing-class-docstring app_label = "bigcommerce_app" diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 7ba3a8920eb0..70459fb0d2b5 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -209,6 +209,35 @@ def create_account_with_params(request, params): # pylint: disable=too-many-sta registration_fields.get('honor_code') != 'hidden' ) + # update this to reflect third-party authentication changes for `required` + # fields to `optional`. + if is_third_party_auth_enabled and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + + # Check to see if BigCommerce backend is the current_provider. + is_bigcommerce_provider_backend = ( + third_party_provider.backend_class.__base__.__name__ == 'BigCommerceCustomerBaseAuth' + ) + + # only do this for `BigCommerceCustomerBaseAuth` backends that have + # `Skip registration form` enabled. + if (is_bigcommerce_provider_backend and third_party_provider.skip_registration_form): + + # make `year_of_birth` optional field. + for provider_field_name, provider_field_value in \ + third_party_provider.get_setting("REGISTRATION_EXTRA_FIELDS").items(): + # check to see if provider field exists in LMS environment. Only make updates if + # environment shows `required` field. + if (params[provider_field_name] == '' and provider_field_name in extra_fields): + # set this to `None` to avoid issue with + # AccountCreationForm.clean_year_of_birth() value error. + params.update({provider_field_name: None}) + + # Update field to be optional if required for third-party only. + if extra_fields[provider_field_name] == 'required': + extra_fields.update({provider_field_name: provider_field_value}) + form = AccountCreationForm( data=params, extra_fields=extra_fields, diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 0321250a3458..4eea253f0dac 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -4,6 +4,7 @@ import copy from importlib import import_module +import logging import re from django import forms @@ -35,6 +36,8 @@ validate_password, ) +LOGGER = logging.getLogger(__name__) + class TrueCheckbox(widgets.CheckboxInput): """ @@ -279,7 +282,7 @@ def clean_year_of_birth(self): """ try: year_str = self.cleaned_data["year_of_birth"] - return int(year_str) if year_str is not None else None + return int(year_str) if year_str is not None and len(year_str) > 0 else None except ValueError: return None @@ -337,6 +340,8 @@ class RegistrationFormFactory: "marketing_emails_opt_in", ] + CUSTOM_FORM_FIELDS = [] + def _is_field_visible(self, field_name): """Check whether a field is visible based on Django settings. """ return self._extra_fields_setting.get(field_name) in ["required", "optional", "optional-exposed"] @@ -410,14 +415,15 @@ def get_registration_form(self, request): HttpResponse """ form_desc = FormDescription("post", self._get_registration_submit_url(request)) - self._apply_third_party_auth_overrides(request, form_desc) # Custom form fields can be added via the form set in settings.REGISTRATION_EXTENSION_FORM custom_form = get_registration_extension_form() if custom_form: - custom_form_field_names = [field_name for field_name, field in custom_form.fields.items()] - else: - custom_form_field_names = [] + self.CUSTOM_FORM_FIELDS = [field_name for field_name, field in custom_form.fields.items()] + + # apply third-party auth provider overrides. + # need to make sure this is done before adding third-party fields. + self._apply_third_party_auth_overrides(request, form_desc) # Go through the fields in the fields order and add them if they are required or visible for field_name in self.field_order: @@ -428,7 +434,7 @@ def get_registration_form(self, request): form_desc, required=self._is_field_required(field_name) ) - elif field_name in custom_form_field_names: + elif field_name in self.CUSTOM_FORM_FIELDS: for custom_field_name, field in custom_form.fields.items(): if field_name == custom_field_name: restrictions = {} @@ -1117,6 +1123,11 @@ def _apply_third_party_auth_overrides(self, request, form_desc): current_provider = third_party_auth.provider.Registry.get_from_pipeline(running_pipeline) if current_provider: + # Check to see if BigCommerce backend is the current_provider. + is_bigcommerce_provider_backend = ( + current_provider.backend_class.__base__.__name__ == 'BigCommerceCustomerBaseAuth' # pylint: disable=line-too-long + ) + # Override username / email / full name field_overrides = current_provider.get_register_form_data( running_pipeline.get('kwargs') @@ -1132,12 +1143,29 @@ def _apply_third_party_auth_overrides(self, request, form_desc): ) or current_provider.sync_learner_profile_data ) - for field_name in self.DEFAULT_FIELDS + self.EXTRA_FIELDS: + for field_name in self.DEFAULT_FIELDS + self.EXTRA_FIELDS + self.CUSTOM_FORM_FIELDS: if field_name in field_overrides: form_desc.override_field_properties( field_name, default=field_overrides[field_name] ) + LOGGER.warning("tpa fields %s - override (%s)", field_name, field_overrides[field_name]) + + # handle this field specifically since the third-party service + # may not pass it and this field is required. + # only do this for `BigCommerceCustomerBaseAuth` backends that have + # `Skip registration form` enabled. + if (is_bigcommerce_provider_backend and current_provider.skip_registration_form): + if (field_name in current_provider.get_setting("REGISTRATION_EXTRA_FIELDS") and + current_provider.skip_registration_form): + + # override the field values from the provider. + form_desc.override_field_properties( + field_name, + default=field_overrides[field_name], + required=(True if current_provider.get_setting("REGISTRATION_EXTRA_FIELDS").get(field_name) == 'required' else False) # pylint: disable=line-too-long, simplifiable-if-expression + ) + if ( field_name not in ['terms_of_service', 'honor_code'] and field_overrides[field_name] and From e649339ad796209ae51964c8a606e14b49ad27cc Mon Sep 17 00:00:00 2001 From: Rebecca David Date: Wed, 3 Jan 2024 10:13:40 -0500 Subject: [PATCH 4/5] fix: Pylint errors wrong-import-order --- .github/workflows/pylint-checks.yml | 2 +- common/djangoapps/third_party_auth/models.py | 16 ++++++++-------- lms/djangoapps/bigcommerce_app/apps.py | 2 +- .../bigcommerce_app/callbacks/views.py | 4 ++-- .../bigcommerce_app/single_click/views.py | 5 +++-- lms/djangoapps/bigcommerce_app/utils.py | 6 +++--- 6 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 7fdafa275a34..584193adeeaf 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -15,7 +15,7 @@ jobs: matrix: include: - module-name: lms-1 - path: "lms/djangoapps/badges/ lms/djangoapps/bigcommerce_app/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/dashboard/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" + path: "lms/djangoapps/badges/ lms/djangoapps/save_for_later/ lms/djangoapps/bigcommerce_app/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" - module-name: lms-2 path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index 3288974c9d6d..bcc438518f0f 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -375,28 +375,28 @@ class EmailProviderConfig(ProviderConfig): backend_name = models.CharField( max_length=50, blank=False, db_index=True, help_text=( - u"Which python-social-auth Email provider backend to use. " + "Which python-social-auth Email provider backend to use. " "The list of backend choices is determined by the THIRD_PARTY_AUTH_BACKENDS setting." # To be precise, it's set by AUTHENTICATION_BACKENDS # which production.py sets from THIRD_PARTY_AUTH_BACKENDS ) ) - key = models.TextField(blank=True, verbose_name=u"Client ID") + key = models.TextField(blank=True, verbose_name="Client ID") secret = models.TextField( blank=True, - verbose_name=u"Client Secret", + verbose_name="Client Secret", help_text=( - u'For increased security, you can avoid storing this in your database by leaving ' + 'For increased security, you can avoid storing this in your database by leaving ' ' this field blank and setting ' 'SOCIAL_AUTH_OAUTH_SECRETS = {"(backend name)": "secret", ...} ' 'in your instance\'s Django settings (or lms.auth.json)' ) ) - other_settings = models.TextField(blank=True, help_text=u"Optional JSON object with advanced settings, if any.") + other_settings = models.TextField(blank=True, help_text="Optional JSON object with advanced settings, if any.") class Meta(object): app_label = "third_party_auth" - verbose_name = u"Provider Configuration (Email)" + verbose_name = "Provider Configuration (Email)" verbose_name_plural = verbose_name def clean(self): @@ -430,7 +430,7 @@ def bigcommerce_retrieve_and_store_customer(self, payload): return BigCommerceAPI.bigcommerce_customer_save(payload) except Exception as excep: # pylint: disable=broad-except log.error( - u"Error decoding Customer payload token and storing in database." + "Error decoding Customer payload token and storing in database." ) return internal_server_error(excep) @@ -440,7 +440,7 @@ def bigcommerce_save_store_customer_platform_user(self, payload): return BigCommerceAPI.bigcommerce_store_customer_platform_user_save(payload) except Exception as excep: # pylint: disable=broad-except log.error( - u"Error decoding Customer payload token and storing in database." + "Error decoding Customer payload token and storing in database." ) return internal_server_error(excep) diff --git a/lms/djangoapps/bigcommerce_app/apps.py b/lms/djangoapps/bigcommerce_app/apps.py index 76960a60e885..57a79e887266 100644 --- a/lms/djangoapps/bigcommerce_app/apps.py +++ b/lms/djangoapps/bigcommerce_app/apps.py @@ -12,7 +12,7 @@ class BigCommerceAppConfig(AppConfig): """ Application Configuration for BigCommerce. """ - name = u'lms.djangoapps.bigcommerce_app' + name = 'lms.djangoapps.bigcommerce_app' def ready(self): """ diff --git a/lms/djangoapps/bigcommerce_app/callbacks/views.py b/lms/djangoapps/bigcommerce_app/callbacks/views.py index a8d91500e719..c552ed325a5a 100644 --- a/lms/djangoapps/bigcommerce_app/callbacks/views.py +++ b/lms/djangoapps/bigcommerce_app/callbacks/views.py @@ -8,6 +8,8 @@ from django.shortcuts import redirect, reverse # from django.urls import reverse +from bigcommerce.api import BigcommerceApi + from lms.djangoapps.bigcommerce_app.models import Store, AdminUser, StoreAdminUser from lms.djangoapps.bigcommerce_app.utils import ( internal_server_error, @@ -16,8 +18,6 @@ platform_lms_url ) -from bigcommerce.api import BigcommerceApi - LOGGER = logging.getLogger(__name__) diff --git a/lms/djangoapps/bigcommerce_app/single_click/views.py b/lms/djangoapps/bigcommerce_app/single_click/views.py index efe4045e53e8..97fc14fd459f 100644 --- a/lms/djangoapps/bigcommerce_app/single_click/views.py +++ b/lms/djangoapps/bigcommerce_app/single_click/views.py @@ -7,12 +7,13 @@ from django.http import HttpResponse from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt + +from bigcommerce.api import BigcommerceApi + from common.djangoapps.edxmako.shortcuts import render_to_response from lms.djangoapps.bigcommerce_app.models import StoreAdminUser from lms.djangoapps.bigcommerce_app.utils import client_id -from bigcommerce.api import BigcommerceApi - LOGGER = logging.getLogger(__name__) # _ = translation.ugettext diff --git a/lms/djangoapps/bigcommerce_app/utils.py b/lms/djangoapps/bigcommerce_app/utils.py index 9ce091db5efd..f7ebac40de6e 100644 --- a/lms/djangoapps/bigcommerce_app/utils.py +++ b/lms/djangoapps/bigcommerce_app/utils.py @@ -9,6 +9,9 @@ from opaque_keys.edx.keys import CourseKey +import bigcommerce.api as bigcommerce_client +from bigcommerce.resources.products import ProductCustomFields + from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangolib.markup import HTML @@ -20,9 +23,6 @@ StoreCustomerPlatformUser ) -import bigcommerce.api as bigcommerce_client -from bigcommerce.resources.products import ProductCustomFields - LOGGER = logging.getLogger(__name__) From f24f5fc3ed82b6aa01f69e709067acfe17b8a967 Mon Sep 17 00:00:00 2001 From: Rebecca David Date: Wed, 3 Jan 2024 10:33:02 -0500 Subject: [PATCH 5/5] fix: conflicting migrations error --- ...o_20220121_1956_0008_auto_20220324_1422.py | 14 +++++++++++ .../migrations/0010_auto_20240103_1553.py | 23 +++++++++++++++++++ .../migrations/0010_auto_20240103_1553.py | 21 +++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 common/djangoapps/third_party_auth/migrations/0009_merge_0007_auto_20220121_1956_0008_auto_20220324_1422.py create mode 100644 common/djangoapps/third_party_auth/migrations/0010_auto_20240103_1553.py create mode 100644 lms/djangoapps/bigcommerce_app/migrations/0010_auto_20240103_1553.py diff --git a/common/djangoapps/third_party_auth/migrations/0009_merge_0007_auto_20220121_1956_0008_auto_20220324_1422.py b/common/djangoapps/third_party_auth/migrations/0009_merge_0007_auto_20220121_1956_0008_auto_20220324_1422.py new file mode 100644 index 000000000000..fe1909f8aec1 --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0009_merge_0007_auto_20220121_1956_0008_auto_20220324_1422.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.16 on 2024-01-03 15:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0007_auto_20220121_1956'), + ('third_party_auth', '0008_auto_20220324_1422'), + ] + + operations = [ + ] diff --git a/common/djangoapps/third_party_auth/migrations/0010_auto_20240103_1553.py b/common/djangoapps/third_party_auth/migrations/0010_auto_20240103_1553.py new file mode 100644 index 000000000000..00bd99e8e09d --- /dev/null +++ b/common/djangoapps/third_party_auth/migrations/0010_auto_20240103_1553.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.16 on 2024-01-03 15:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('third_party_auth', '0009_merge_0007_auto_20220121_1956_0008_auto_20220324_1422'), + ] + + operations = [ + migrations.AddField( + model_name='emailproviderconfig', + name='was_valid_at', + field=models.DateTimeField(blank=True, help_text='Timestamped field that indicates a user has successfully logged in using this configuration at least once.', null=True), + ), + migrations.AlterField( + model_name='emailproviderconfig', + name='name', + field=models.CharField(blank=True, help_text='Name of this provider (shown to users)', max_length=50), + ), + ] diff --git a/lms/djangoapps/bigcommerce_app/migrations/0010_auto_20240103_1553.py b/lms/djangoapps/bigcommerce_app/migrations/0010_auto_20240103_1553.py new file mode 100644 index 000000000000..f83e0baf4ea6 --- /dev/null +++ b/lms/djangoapps/bigcommerce_app/migrations/0010_auto_20240103_1553.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.16 on 2024-01-03 15:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bigcommerce_app', '0009_auto_20220823_2237'), + ] + + operations = [ + migrations.RemoveField( + model_name='customer', + name='bc_country_code', + ), + migrations.RemoveField( + model_name='customer', + name='bc_postal_code', + ), + ]