Skip to content

Commit 61eb27d

Browse files
committed
feat: make marketing email and research opt-in checkboxs selectively ignorable
We want to support a flow for SSO-enabled Enterprise customers who have agreed off-platform that none of their learners will opt-in to marketing emails or sharing research data. This change proposes to do so by adding an optional field that, when enabled, disables the presence of the two checkboxes on this registration form and sets their values to false. ENT-11401
1 parent 85e81b3 commit 61eb27d

9 files changed

Lines changed: 382 additions & 26 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ lms/envs/private.py
1313
cms/envs/private.py
1414
.venv/
1515
CLAUDE.md
16+
.claude/
1617
AGENTS.md
1718
# end-noclean
1819

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated migration for adding optional checkbox skip configuration field
2+
3+
from django.db import migrations, models
4+
import django.utils.translation
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('third_party_auth', '0013_default_site_id_wrapper_function'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='samlproviderconfig',
16+
name='skip_registration_optional_checkboxes',
17+
field=models.BooleanField(
18+
default=False,
19+
help_text=django.utils.translation.gettext_lazy(
20+
"If enabled, optional checkboxes (marketing emails opt-in, etc.) will not be rendered "
21+
"on the registration form for users registering via this provider. When these checkboxes "
22+
"are skipped, their values are inferred as False (opted out)."
23+
),
24+
),
25+
),
26+
]

common/djangoapps/third_party_auth/models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,14 @@ class SAMLProviderConfig(ProviderConfig):
745745
"immediately after authenticating with the third party instead of the login page."
746746
),
747747
)
748+
skip_registration_optional_checkboxes = models.BooleanField(
749+
default=False,
750+
help_text=_(
751+
"If enabled, optional checkboxes (marketing emails opt-in, etc.) will not be rendered "
752+
"on the registration form for users registering via this provider. When these checkboxes "
753+
"are skipped, their values are inferred as False (opted out)."
754+
),
755+
)
748756
other_settings = models.TextField(
749757
verbose_name="Advanced settings", blank=True,
750758
help_text=(

lms/static/js/student_account/views/RegisterView.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
);
5959
this.currentProvider = data.thirdPartyAuth.currentProvider || '';
6060
this.syncLearnerProfileData = data.thirdPartyAuth.syncLearnerProfileData || false;
61+
this.skipRegistrationOptionalCheckboxes = data.thirdPartyAuth.skipRegistrationOptionalCheckboxes || false;
6162
this.errorMessage = data.thirdPartyAuth.errorMessage || '';
6263
this.platformName = data.platformName;
6364
this.autoSubmit = data.thirdPartyAuth.autoSubmitRegForm;
@@ -156,6 +157,7 @@
156157
fields: fields,
157158
currentProvider: this.currentProvider,
158159
syncLearnerProfileData: this.syncLearnerProfileData,
160+
skipRegistrationOptionalCheckboxes: this.skipRegistrationOptionalCheckboxes,
159161
providers: this.providers,
160162
hasSecondaryProviders: this.hasSecondaryProviders,
161163
platformName: this.platformName,

lms/templates/student_account/register.underscore

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,16 @@
5656
<div class="form-fields <% if (context.is_require_third_party_auth_enabled) { %>hidden<% } %>">
5757
<%= context.fields /* xss-lint: disable=underscore-not-escaped */ %>
5858

59-
<div class="form-field checkbox-optional_fields_toggle">
60-
<input type="checkbox" id="toggle_optional_fields" class="input-block checkbox"">
61-
<label for="toggle_optional_fields">
62-
<span class="label-text-small">
63-
<%- gettext("Support education research by providing additional information") %>
64-
</span>
65-
</label>
66-
</div>
59+
<% if (!context.skipRegistrationOptionalCheckboxes) { %>
60+
<div class="form-field checkbox-optional_fields_toggle">
61+
<input type="checkbox" id="toggle_optional_fields" class="input-block checkbox"">
62+
<label for="toggle_optional_fields">
63+
<span class="label-text-small">
64+
<%- gettext("Support education research by providing additional information") %>
65+
</span>
66+
</label>
67+
</div>
68+
<% } %>
6769

6870
<button type="submit" class="action action-primary action-update js-register register-button">
6971
<% if ( context.registerFormSubmitButtonText ) { %><%- context.registerFormSubmitButtonText %><% } else { %><%- gettext("Create Account") %><% } %>

openedx/core/djangoapps/user_authn/views/registration_form.py

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import copy
6+
import logging
67
import re
78
from importlib import import_module
89

@@ -18,6 +19,7 @@
1819
from eventtracking import tracker
1920

2021
from common.djangoapps import third_party_auth
22+
from common.djangoapps.third_party_auth.models import SAMLProviderConfig
2123
from common.djangoapps.edxmako.shortcuts import marketing_link
2224
from common.djangoapps.student.models import CourseEnrollmentAllowed, UserProfile, email_exists_or_retired
2325
from common.djangoapps.util.password_policy_validators import (
@@ -36,6 +38,9 @@
3638
from openedx.features.enterprise_support.api import enterprise_customer_for_request
3739

3840

41+
log = logging.getLogger(__name__)
42+
43+
3944
class TrueCheckbox(widgets.CheckboxInput):
4045
"""
4146
A checkbox widget that only accepts "true" (case-insensitive) as true.
@@ -410,6 +415,56 @@ def __init__(self):
410415
field_order.extend(sorted(difference))
411416

412417
self.field_order = field_order
418+
self.request = None # Will be set by get_registration_form
419+
420+
def _get_saml_provider_config(self):
421+
"""
422+
Get the SAML provider config for the current request's running pipeline.
423+
424+
Returns:
425+
SAMLProviderConfig or None: The SAML provider config if found, None otherwise
426+
"""
427+
if not self.request or not third_party_auth.is_enabled():
428+
return None
429+
430+
running_pipeline = third_party_auth.pipeline.get(self.request)
431+
if not running_pipeline:
432+
return None
433+
434+
try:
435+
# idp_name can be in kwargs directly or in kwargs['details']
436+
saml_provider_name = running_pipeline.get('kwargs', {}).get('idp_name')
437+
if not saml_provider_name:
438+
saml_provider_name = (
439+
running_pipeline.get('kwargs', {})
440+
.get('details', {})
441+
.get('idp_name')
442+
)
443+
444+
if not saml_provider_name:
445+
return None
446+
447+
try:
448+
# Try to find the SAML provider config
449+
# First try with current_set(), then fall back to direct query
450+
try:
451+
return SAMLProviderConfig.objects.current_set().get(
452+
slug=saml_provider_name
453+
)
454+
except SAMLProviderConfig.DoesNotExist:
455+
# Fallback to direct query without current_set()
456+
return SAMLProviderConfig.objects.get(
457+
slug=saml_provider_name
458+
)
459+
except SAMLProviderConfig.DoesNotExist:
460+
log.debug(
461+
"SAML provider config not found for idp_name: %s",
462+
saml_provider_name
463+
)
464+
return None
465+
except Exception as exc: # pylint: disable=broad-except
466+
log.debug("Error getting SAML provider config: %s", str(exc))
467+
return None
413468

414469
def get_registration_form(self, request):
415470
"""Return a description of the registration form.
@@ -426,6 +481,7 @@ def get_registration_form(self, request):
426481
Returns:
427482
HttpResponse
428483
"""
484+
self.request = request
429485
form_desc = FormDescription("post", self._get_registration_submit_url(request))
430486
self._apply_third_party_auth_overrides(request, form_desc)
431487

@@ -693,6 +749,11 @@ def _add_year_of_birth_field(self, form_desc, required=True):
693749

694750
def _add_marketing_emails_opt_in_field(self, form_desc, required=False):
695751
"""Add a marketing email checkbox to form description.
752+
753+
If a SAML provider config has skip_registration_optional_checkboxes=True,
754+
the field will default to False (opt-out) and not be required, overriding
755+
the global settings.
756+
696757
Arguments:
697758
form_desc: A form description
698759
Keyword Arguments:
@@ -703,13 +764,29 @@ def _add_marketing_emails_opt_in_field(self, form_desc, required=False):
703764
platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME),
704765
)
705766

767+
# Default: checkbox is checked, field requirement follows the passed parameter
768+
default_value = True
769+
field_required = required
770+
771+
# Check if SAML provider wants to skip optional checkboxes
772+
# This overrides both global settings and provider field overrides
773+
saml_config = self._get_saml_provider_config()
774+
if saml_config and saml_config.skip_registration_optional_checkboxes:
775+
log.info(
776+
"SAML provider %s has skip_registration_optional_checkboxes=True, "
777+
"setting default to False and required to False",
778+
saml_config.slug
779+
)
780+
default_value = False # User opts out by default when field is skipped
781+
field_required = False # Make field optional
782+
706783
form_desc.add_field(
707784
'marketing_emails_opt_in',
708785
label=opt_in_label,
709786
field_type="checkbox",
710787
exposed=True,
711-
default=True, # the checkbox will automatically be checked; meaning user has opted in
712-
required=required,
788+
default=default_value,
789+
required=field_required,
713790
)
714791

715792
def _add_field_with_configurable_select_options(self, field_name, field_label, form_desc, required=False):
@@ -1150,22 +1227,47 @@ def _apply_third_party_auth_overrides(self, request, form_desc):
11501227

11511228
for field_name in self.DEFAULT_FIELDS + self.EXTRA_FIELDS:
11521229
if field_name in field_overrides:
1153-
form_desc.override_field_properties(
1154-
field_name, default=field_overrides[field_name]
1155-
)
1156-
1157-
if (
1158-
field_name not in ['terms_of_service', 'honor_code'] and
1159-
field_overrides[field_name] and
1160-
hide_registration_fields_except_tos
1161-
):
1230+
# Special handling for marketing_emails_opt_in:
1231+
# If SAML provider config has skip_registration_optional_checkboxes=True,
1232+
# don't let the provider's get_register_form_data override the default
1233+
skip_override = False
1234+
if field_name == 'marketing_emails_opt_in':
1235+
saml_config = self._get_saml_provider_config()
1236+
if saml_config and saml_config.skip_registration_optional_checkboxes:
1237+
log.debug(
1238+
"Skipping provider override for marketing_emails_opt_in "
1239+
"due to SAML config for provider: %s",
1240+
saml_config.slug
1241+
)
1242+
skip_override = True
1243+
1244+
if not skip_override:
11621245
form_desc.override_field_properties(
1163-
field_name,
1164-
field_type="hidden",
1165-
label="",
1166-
instructions="",
1246+
field_name, default=field_overrides[field_name]
11671247
)
11681248

1249+
if (
1250+
field_name not in ['terms_of_service', 'honor_code'] and
1251+
field_overrides[field_name] and
1252+
hide_registration_fields_except_tos
1253+
):
1254+
# When hiding a field, set default to False for checkbox fields
1255+
# like marketing_emails_opt_in to avoid auto-opting users in
1256+
field_default = field_overrides[field_name]
1257+
if field_name == 'marketing_emails_opt_in':
1258+
field_default = False
1259+
log.info(
1260+
"Hiding marketing_emails_opt_in field and setting default to False"
1261+
)
1262+
1263+
form_desc.override_field_properties(
1264+
field_name,
1265+
field_type="hidden",
1266+
default=field_default,
1267+
label="",
1268+
instructions="",
1269+
)
1270+
11691271
# Hide the confirm_email field
11701272
form_desc.override_field_properties(
11711273
"confirm_email",

openedx/core/djangoapps/user_authn/views/tests/test_logistration.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,8 @@ def _assert_third_party_auth_data(self, response, current_backend, current_provi
569569
"errorMessage": None,
570570
"registerFormSubmitButtonText": "Create Account",
571571
"syncLearnerProfileData": False,
572-
"pipeline_user_details": {"email": "test@test.com"} if add_user_details else {}
572+
"pipeline_user_details": {"email": "test@test.com"} if add_user_details else {},
573+
"skipRegistrationOptionalCheckboxes": False
573574
}
574575
if expected_ec is not None:
575576
# If we set an EnterpriseCustomer, third-party auth providers ought to be hidden.
@@ -600,7 +601,8 @@ def _assert_saml_auth_data_with_error(
600601
'errorMessage': expected_error_message,
601602
'registerFormSubmitButtonText': 'Create Account',
602603
'syncLearnerProfileData': False,
603-
'pipeline_user_details': {'response': {'idp_name': 'testshib'}}
604+
'pipeline_user_details': {'response': {'idp_name': 'testshib'}},
605+
'skipRegistrationOptionalCheckboxes': False
604606
}
605607
auth_info = dump_js_escaped_json(auth_info)
606608

0 commit comments

Comments
 (0)