diff --git a/.github/workflows/check-for-tutorial-prs.yml b/.github/workflows/check-for-tutorial-prs.yml
new file mode 100644
index 000000000000..6920542ac187
--- /dev/null
+++ b/.github/workflows/check-for-tutorial-prs.yml
@@ -0,0 +1,35 @@
+# This workflow detects PRs that make changes to lms/templates/dashboard.html
+# and only lms/templates/dashboard.html. This is the file that users are
+# guided through changing in the Open edX tutorial:
+# https://docs.openedx.org/en/latest/developers/quickstarts/first_openedx_pr.html#exercise-update-the-learner-dashboard
+
+# If this is the only file changed in the PR, we comment on the PR congratulating
+# the user and letting others know that this is not a community PR in need of
+# review. CODEOWNERS will tag a triaging team to provide reviews & ultimately
+# close the PR.
+
+name: Check for Tutorial PR
+description: Welcome contributors making their first PR from the tutorial
+on:
+ pull_request:
+ types: [opened]
+ paths:
+ - 'lms/templates/dashboard.html'
+
+jobs:
+ # Provide helpful bot comment
+ comment:
+ runs-on: ubuntu-latest
+ name: provide helpful bot comment
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Comment PR
+ uses: thollander/actions-comment-pull-request@v1
+ with:
+ message: |
+ Thank you for your pull request! Congratulations on completing the Open edX tutorial! A team member will be by to take a look shortly.
+ To those watching community pull requests: No need to worry about this one, a tCRIL team member will be taking care of it.
+ For this PR's author: If this is a PR that is NOT coming from the Open edX tutorial, please comment and let us know to disregard this message.
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py
index 62efb6019835..44bfdea32908 100644
--- a/cms/djangoapps/contentstore/signals/handlers.py
+++ b/cms/djangoapps/contentstore/signals/handlers.py
@@ -11,7 +11,7 @@
from django.db import transaction
from django.dispatch import receiver
from edx_toggles.toggles import SettingToggle
-from edx_event_bus_kafka.publishing.event_producer import send_to_event_bus
+from edx_event_bus_kafka import get_producer
from opaque_keys.edx.keys import CourseKey
from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData
from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED
@@ -156,9 +156,9 @@ def listen_for_course_catalog_info_changed(sender, signal, **kwargs):
"""
Publish COURSE_CATALOG_INFO_CHANGED signals onto the event bus.
"""
- send_to_event_bus(
+ get_producer().send(
signal=COURSE_CATALOG_INFO_CHANGED, topic='course-catalog-info-changed',
- event_key_field='catalog_info.course_key', event_data={'catalog_info': kwargs['catalog_info']}
+ event_key_field='catalog_info.course_key', event_data={'catalog_info': kwargs['catalog_info']},
)
diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py
index 0194ce9f5721..f212bc39f1d8 100644
--- a/cms/djangoapps/contentstore/tests/tests.py
+++ b/cms/djangoapps/contentstore/tests/tests.py
@@ -6,6 +6,7 @@
import datetime
import time
from unittest import mock
+from urllib.parse import quote_plus
from ddt import data, ddt, unpack
from django.conf import settings
@@ -174,10 +175,11 @@ def test_signin_and_signup_buttons_index_page(self, allow_account_creation, asse
with mock.patch.dict(settings.FEATURES, {"ALLOW_PUBLIC_ACCOUNT_CREATION": allow_account_creation}):
response = self.client.get(reverse('homepage'))
assertion_method = getattr(self, assertion_method_name)
+ login_url = quote_plus(f"http://testserver{settings.LOGIN_URL}")
assertion_method(
response,
- 'Sign Up'.format
- (settings.LMS_ROOT_URL)
+ f'Sign Up'
)
self.assertContains(
response,
diff --git a/cms/envs/common.py b/cms/envs/common.py
index c54416237c03..010e045034ff 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -2686,3 +2686,12 @@
COURSE_LIVE_GLOBAL_CREDENTIALS = {}
PERSONALIZED_RECOMMENDATION_COOKIE_NAME = 'edx-user-personalized-recommendation'
+
+######################## Registration ########################
+
+# Social-core setting that allows inactive users to be able to
+# log in. The only case it's used is when user registering a new account through the LMS.
+INACTIVE_USER_LOGIN = True
+
+# Redirect URL for inactive user. If not set, user will be redirected to /login after the login itself (loop)
+INACTIVE_USER_URL = f'http://{CMS_BASE}'
diff --git a/cms/envs/production.py b/cms/envs/production.py
index 17947f353fcb..ece9b85a178e 100644
--- a/cms/envs/production.py
+++ b/cms/envs/production.py
@@ -648,3 +648,5 @@ def get_env_setting(setting):
"SECRET": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_SECRET', None),
"URL": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL', None),
}
+
+INACTIVE_USER_URL = f'http{"s" if HTTPS == "on" else ""}://{CMS_BASE}'
diff --git a/cms/static/sass/views/_dashboard.scss b/cms/static/sass/views/_dashboard.scss
index 27e62c0c6e6d..3e5a42937892 100644
--- a/cms/static/sass/views/_dashboard.scss
+++ b/cms/static/sass/views/_dashboard.scss
@@ -151,6 +151,7 @@
.action-primary {
@extend %btn-primary-blue;
@extend %t-action3;
+ color: $white;
}
// specific - request button
diff --git a/cms/templates/howitworks.html b/cms/templates/howitworks.html
index 014ad2798dbe..b04a207eba6f 100644
--- a/cms/templates/howitworks.html
+++ b/cms/templates/howitworks.html
@@ -5,6 +5,7 @@
<%!
from django.conf import settings
from django.utils.translation import gettext as _
+ from urllib.parse import quote_plus
from openedx.core.djangolib.markup import HTML, Text
%>
@@ -162,7 +163,7 @@
${_("Sign Up for {studio_name} Today!").format(studio_name=settin
-
- ${_("Sign Up & Start Making Your {platform_name} Course").format(platform_name=settings.PLATFORM_NAME)}
+ ${_("Sign Up & Start Making Your {platform_name} Course").format(platform_name=settings.PLATFORM_NAME)}
-
${_("Already have a {studio_name} Account? Sign In").format(studio_name=settings.STUDIO_SHORT_NAME)}
diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html
index eb4431ac0d7f..3c0219824da9 100644
--- a/cms/templates/widgets/header.html
+++ b/cms/templates/widgets/header.html
@@ -6,6 +6,7 @@
from django.conf import settings
from django.urls import reverse
from django.utils.translation import gettext as _
+ from urllib.parse import quote_plus
from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.utils import get_pages_and_resources_url
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND
@@ -266,7 +267,7 @@
${_("Account Navigation")}
% if static.get_value('ALLOW_PUBLIC_ACCOUNT_CREATION', settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION')):
-
- ${_("Sign Up")}
+ ${_("Sign Up")}
% endif
-
diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py
index 7322483b8d8d..5fbcd1513a80 100644
--- a/common/djangoapps/course_modes/views.py
+++ b/common/djangoapps/course_modes/views.py
@@ -324,11 +324,11 @@ def post(self, request, course_id):
# been configured. However, alternative enrollment workflows have been introduced into the
# system, such as third-party discovery. These workflows result in learners arriving
# directly at this screen, and they will not necessarily be pre-enrolled in the audit mode.
- CourseEnrollment.enroll(request.user, course_key, CourseMode.AUDIT)
+ CourseEnrollment.enroll(request.user, course_key, CourseMode.AUDIT, request=request)
return self._redirect_to_course_or_dashboard(course, course_key, user)
if requested_mode == 'honor':
- CourseEnrollment.enroll(user, course_key, mode=requested_mode)
+ CourseEnrollment.enroll(user, course_key, mode=requested_mode, request=request)
return self._redirect_to_course_or_dashboard(course, course_key, user)
mode_info = allowed_modes[requested_mode]
diff --git a/common/djangoapps/student/helpers.py b/common/djangoapps/student/helpers.py
index 71b46d5af5ec..f0f62292f17e 100644
--- a/common/djangoapps/student/helpers.py
+++ b/common/djangoapps/student/helpers.py
@@ -20,7 +20,7 @@
from django.db import IntegrityError, ProgrammingError, transaction
from django.urls import NoReverseMatch, reverse
from django.utils.translation import gettext as _
-from pytz import UTC
+from pytz import UTC, timezone
from common.djangoapps import third_party_auth
from common.djangoapps.course_modes.models import CourseMode
@@ -50,10 +50,15 @@
from lms.djangoapps.verify_student.models import VerificationDeadline
from lms.djangoapps.verify_student.services import IDVerificationService
from lms.djangoapps.verify_student.utils import is_verification_expiring_soon, verification_for_datetime
+from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_with_access
+from lms.djangoapps.courseware.date_summary import TodaysDate
+from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs
+from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer
from openedx.core.djangoapps.content.block_structure.exceptions import UsageKeyNotInBlockStructure
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming.helpers import get_themes
from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect
+from openedx.core.lib.time_zone_utils import get_time_zone_offset
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
# Enumeration of per-course verification statuses
@@ -814,3 +819,89 @@ def user_has_passing_grade_in_course(enrollment):
except AttributeError:
pass
return False
+
+
+def get_instructors(course_run, marketing_root_url):
+ """
+ Get course instructors.
+ """
+ instructors = []
+ staff = course_run.get('staff', [])
+ for instructor in staff:
+ instructor = {
+ 'name': f"{instructor.get('given_name')} {instructor.get('family_name')}",
+ 'profile_image_url': instructor.get('profile_image_url'),
+ 'organization_name': (instructor.get('position').get('organization_name')
+ if instructor.get('position') else ''),
+ 'bio_url': f"{marketing_root_url}/bio/{instructor.get('slug')}"
+ }
+ instructors.append(instructor)
+
+ return instructors
+
+
+def _prepare_date_block(block, block_date, user_timezone):
+ """
+ Prepare date block which include assignment related data for this date
+ """
+ timezone_offset = get_time_zone_offset(user_timezone, block_date)
+ block = {
+ 'title': block.get('title', ''),
+ 'assignment_type': block.get('assignment_type', '') or '',
+ 'assignment_count': 0,
+ 'link': block.get('link', ''),
+ 'date': block_date,
+ 'due_date': block_date.strftime("%a, %b %d, %Y"),
+ 'due_time': (f'{block_date.strftime("%H:%M %p")} GMT{timezone_offset}' if block.get('assignment_type') else '')
+ }
+ return block
+
+
+def get_course_dates_for_email(user, course_id, request):
+ """
+ Getting nearest dates from today one would be before today and one
+ would be after today.
+ """
+ user_timezone_locale = user_timezone_locale_prefs(request)
+ user_timezone = timezone(user_timezone_locale['user_timezone'] or str(UTC))
+
+ course = get_course_with_access(user, 'load', course_id)
+ date_blocks = get_course_date_blocks(course, user, request, include_access=True, include_past_dates=True)
+ date_blocks = [block for block in date_blocks if not isinstance(block, TodaysDate)]
+ blocks = DateSummarySerializer(date_blocks, many=True).data
+
+ today = datetime.now(user_timezone)
+ course_date = {
+ 'title': '',
+ 'assignment_type': '',
+ 'link': '',
+ 'assignment_count': 0,
+ 'date': '',
+ 'due_date': today.strftime("%a, %b %d, %Y"),
+ 'due_time': ''
+ }
+ course_date_list = [{**course_date, }, {**course_date, 'date': today}, {**course_date}]
+ for block in blocks:
+ block_date = datetime.strptime(block.get('date')[:19], '%Y-%m-%dT%H:%M:%S')
+ block_date = block_date.replace(tzinfo=UTC)
+ block_date = block_date.astimezone(user_timezone)
+
+ if block_date < today:
+ if block_date == course_date_list[0]['date'] and block.get('assignment_type'):
+ course_date_list[0]['assignment_count'] += 1
+ else:
+ course_date_list[0].update(_prepare_date_block(block, block_date, user_timezone))
+
+ if block_date == today:
+ if block.get('assignment_type') and course_date_list[1]['assignment_type'] != '':
+ course_date_list[1]['assignment_count'] += 1
+ else:
+ course_date_list[1].update(_prepare_date_block(block, block_date, user_timezone))
+
+ if block_date > today:
+ if block_date == course_date_list[2]['date'] and block.get('assignment_type'):
+ course_date_list[2]['assignment_count'] += 1
+ if course_date_list[2]['date'] == '':
+ course_date_list[2].update(_prepare_date_block(block, block_date, user_timezone))
+
+ return course_date_list
diff --git a/common/djangoapps/student/management/tests/test_transfer_students.py b/common/djangoapps/student/management/tests/test_transfer_students.py
index f42ab66e10ad..f9ee5c808a15 100644
--- a/common/djangoapps/student/management/tests/test_transfer_students.py
+++ b/common/djangoapps/student/management/tests/test_transfer_students.py
@@ -20,6 +20,7 @@
)
from common.djangoapps.student.signals import UNENROLL_DONE
from common.djangoapps.student.tests.factories import UserFactory
+from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
@@ -54,7 +55,8 @@ def assert_unenroll_signal(self, skip_refund=False, **kwargs): # pylint: disab
assert skip_refund
self.signal_fired = True
- def test_transfer_students(self):
+ @patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ def test_transfer_students(self, mock_get_course_run_details):
"""
Verify the transfer student command works as intended.
"""
@@ -65,6 +67,12 @@ def test_transfer_students(self):
# Original Course
original_course_location = locator.CourseLocator('Org0', 'Course0', 'Run0')
course = self._create_course(original_course_location)
+
+ course_run = CourseRunFactory.create(key=course.id)
+ course_run['min_effort'] = 1
+ course_run['enrollment_count'] = 12345
+
+ mock_get_course_run_details.return_value = course_run
# Enroll the student in 'verified'
CourseEnrollment.enroll(student, course.id, mode='verified')
diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py
index 2219afb5722f..2b4397ba4b35 100644
--- a/common/djangoapps/student/models.py
+++ b/common/djangoapps/student/models.py
@@ -1426,7 +1426,7 @@ def is_enrollment_closed(cls, user, course):
from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
return not user.has_perm(ENROLL_IN_COURSE, course)
- def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterprise_uuid=None):
+ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterprise_uuid=None, request=None):
"""
Updates an enrollment for a user in a class. This includes options
like changing the mode, toggling is_active True/False, etc.
@@ -1491,7 +1491,7 @@ def update_enrollment(self, mode=None, is_active=None, skip_refund=False, enterp
if activation_changed:
if self.is_active:
- self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED, enterprise_uuid=enterprise_uuid)
+ self.emit_event(EVENT_NAME_ENROLLMENT_ACTIVATED, enterprise_uuid=enterprise_uuid, request=request)
else:
UNENROLL_DONE.send(sender=None, course_enrollment=self, skip_refund=skip_refund)
self.emit_event(EVENT_NAME_ENROLLMENT_DEACTIVATED)
@@ -1554,11 +1554,16 @@ def send_signal_full(cls, event, user=user, mode=mode, course_id=None, cost=None
mode=mode, course_id=course_id,
cost=cost, currency=currency)
- def emit_event(self, event_name, enterprise_uuid=None):
+ def emit_event(self, event_name, enterprise_uuid=None, request=None): # pylint: disable=too-many-statements
"""
Emits an event to explicitly track course enrollment and unenrollment.
"""
+ from common.djangoapps.student.helpers import get_course_dates_for_email, get_instructors
+ from common.djangoapps.student.toggles import should_send_redesign_email
from openedx.core.djangoapps.schedules.config import set_up_external_updates_for_enrollment
+ from openedx.core.djangoapps.catalog.api import get_course_run_details
+ from openedx.core.djangoapps.catalog.utils import get_owners_for_course, get_course_uuid_for_course
+ from openedx.features.course_experience import ENABLE_COURSE_GOALS
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.features.enterprise_support.utils import is_enterprise_learner
@@ -1596,6 +1601,55 @@ def emit_event(self, event_name, enterprise_uuid=None):
segment_traits['email'] = self.user.email
if event_name == EVENT_NAME_ENROLLMENT_ACTIVATED:
+ studio_request = settings.ROOT_URLCONF == 'cms.urls'
+ extra_segment_properties = {
+ 'studio_request': studio_request
+ }
+ if not studio_request and should_send_redesign_email():
+ if not request:
+ request = crum.get_current_request()
+
+ marketing_root_url = settings.MKTG_URLS.get('ROOT')
+ course_dates_list = get_course_dates_for_email(self.user, self.course.id, request)
+ course_run_fields = [
+ 'key', 'title', 'short_description', 'marketing_url', 'pacing_type', 'min_effort',
+ 'max_effort', 'weeks_to_complete', 'enrollment_count', 'image', 'staff',
+ ]
+ owners, course_run = None, None
+ try:
+ course_uuid = get_course_uuid_for_course(str(self.course_id))
+ owners = get_owners_for_course(course_uuid=course_uuid)
+ course_run = get_course_run_details(str(self.course_id), course_run_fields)
+ except Exception: # pylint: disable=broad-except
+ pass
+
+ if course_run:
+ instructors = get_instructors(course_run, marketing_root_url)
+ extra_segment_properties.update({
+ 'instructors': instructors,
+ 'instructors_count': 'even' if len(instructors) % 2 == 0 else 'odd',
+ 'pacing_type': course_run.get('pacing_type'),
+ 'min_effort': course_run.get('min_effort'),
+ 'max_effort': course_run.get('max_effort'),
+ 'weeks_to_complete': course_run.get('weeks_to_complete'),
+ 'learners_count': '{:,}'.format(course_run.get('enrollment_count')),
+ 'course_title': course_run.get('title'),
+ 'short_description': course_run.get('short_description'),
+ 'marketing_url': course_run.get('marketing_url'),
+ 'banner_image_url': course_run.get('image').get('src') if course_run.get('image') else ''
+ })
+ extra_segment_properties.update({
+ 'goals_enabled': ENABLE_COURSE_GOALS.is_enabled(self.course_id),
+ 'course_date_blocks': course_dates_list,
+ 'partner_image_url': owners[0].get('logo_image_url') if owners else '',
+ 'learner_name': self.user.profile.name,
+ 'course_run_key': str(self.course_id),
+ 'price': self.course_price,
+ 'lms_base_url': configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL),
+ 'learning_base_url': configuration_helpers.get_value('LEARNING_MICROFRONTEND_URL',
+ settings.LEARNING_MICROFRONTEND_URL)
+ })
+ segment_properties.update(extra_segment_properties)
segment_properties['email'] = self.user.email
# This next property is for an experiment, see method's comments for more information
segment_properties['external_course_updates'] = set_up_external_updates_for_enrollment(self.user,
@@ -1640,7 +1694,8 @@ def emit_event(self, event_name, enterprise_uuid=None):
)
@classmethod
- def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=False, enterprise_uuid=None):
+ def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=False,
+ enterprise_uuid=None, request=None):
"""
Enroll a user in a course. This saves immediately.
@@ -1735,7 +1790,7 @@ def enroll(cls, user, course_key, mode=None, check_access=False, can_upgrade=Fal
# User is allowed to enroll if they've reached this point.
enrollment = cls.get_or_create_enrollment(user, course_key)
- enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid)
+ enrollment.update_enrollment(is_active=True, mode=mode, enterprise_uuid=enterprise_uuid, request=request)
enrollment.send_signal(EnrollStatusChange.enroll)
# .. event_implemented_name: COURSE_ENROLLMENT_CREATED
diff --git a/common/djangoapps/student/tests/test_configuration_overrides.py b/common/djangoapps/student/tests/test_configuration_overrides.py
index be4aae913dc9..477f8255f6b0 100644
--- a/common/djangoapps/student/tests/test_configuration_overrides.py
+++ b/common/djangoapps/student/tests/test_configuration_overrides.py
@@ -68,7 +68,7 @@ def setUp(self):
"address1": "foo",
"city": "foo",
"state": "foo",
- "country": "foo",
+ "country": "US",
"company": "foo",
"title": "foo"
}.items()))
diff --git a/common/djangoapps/student/tests/test_enrollment.py b/common/djangoapps/student/tests/test_enrollment.py
index 520f5a594e5e..5e332bc2e5e1 100644
--- a/common/djangoapps/student/tests/test_enrollment.py
+++ b/common/djangoapps/student/tests/test_enrollment.py
@@ -26,6 +26,7 @@
from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory
from common.djangoapps.util.testing import UrlResetMixin
from openedx.core.djangoapps.embargo.test_utils import restrict_course
+from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory
@ddt.ddt
@@ -74,6 +75,17 @@ def setUp(self):
# Set up proctored exam
self._create_proctored_exam(self.proctored_course)
+ course_run = CourseRunFactory.create(key=self.course.id)
+ course_run.update({
+ 'min_effort': 1,
+ 'enrollment_count': 12345
+ })
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
+ self.addCleanup(patch_course_data.stop)
+
def _create_proctored_exam(self, course):
"""
Helper function to create a proctored exam for a given course
diff --git a/common/djangoapps/student/tests/test_retirement.py b/common/djangoapps/student/tests/test_retirement.py
index e41b4f8f8b24..293bdb0f5a75 100644
--- a/common/djangoapps/student/tests/test_retirement.py
+++ b/common/djangoapps/student/tests/test_retirement.py
@@ -266,8 +266,8 @@ class TestRegisterRetiredUsername(TestCase):
"""
# The returned message here varies depending on whether a ValidationError -or-
# an AccountValidationError occurs.
- INVALID_ACCT_ERR_MSG = ('An account with the Public Username', 'already exists.')
- INVALID_ERR_MSG = ('It looks like', 'belongs to an existing account. Try again with a different username.')
+ INVALID_ACCT_ERR_MSG = "An account with the Public Username '{username}' already exists."
+ INVALID_ERR_MSG = "It looks like this username is already taken"
def setUp(self):
super().setUp()
@@ -281,7 +281,7 @@ def setUp(self):
'honor_code': 'true',
}
- def _validate_exiting_username_response(self, orig_username, response, start_msg=INVALID_ACCT_ERR_MSG[0], end_msg=INVALID_ACCT_ERR_MSG[1]): # lint-amnesty, pylint: disable=line-too-long
+ def _validate_exiting_username_response(self, orig_username, response, error_message):
"""
Validates a response stating that a username already exists -or- is invalid.
"""
@@ -289,9 +289,7 @@ def _validate_exiting_username_response(self, orig_username, response, start_msg
obj = json.loads(response.content.decode('utf-8'))
username_msg = obj['username'][0]['user_message']
- assert username_msg.startswith(start_msg)
- assert username_msg.endswith(end_msg)
- assert orig_username in username_msg
+ assert username_msg == error_message
def test_retired_username(self):
"""
@@ -307,7 +305,7 @@ def test_retired_username(self):
# Attempt to create another account with the same username that's been retired.
self.url_params['username'] = orig_username
response = self.client.post(self.url, self.url_params)
- self._validate_exiting_username_response(orig_username, response, self.INVALID_ERR_MSG[0], self.INVALID_ERR_MSG[1]) # lint-amnesty, pylint: disable=line-too-long
+ self._validate_exiting_username_response(orig_username, response, self.INVALID_ERR_MSG)
def test_username_close_to_retired_format_active(self):
"""
@@ -315,6 +313,9 @@ def test_username_close_to_retired_format_active(self):
"""
# Attempt to create an account with a username similar to the format of a retired username
# which matches the RETIRED_USERNAME_PREFIX setting.
- self.url_params['username'] = settings.RETIRED_USERNAME_PREFIX
+ username = settings.RETIRED_USERNAME_PREFIX
+ self.url_params['username'] = username
response = self.client.post(self.url, self.url_params)
- self._validate_exiting_username_response(settings.RETIRED_USERNAME_PREFIX, response)
+ self._validate_exiting_username_response(
+ username, response, self.INVALID_ACCT_ERR_MSG.format(username=username)
+ )
diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py
index 14a3c360e838..72938e1b8d0e 100644
--- a/common/djangoapps/student/tests/tests.py
+++ b/common/djangoapps/student/tests/tests.py
@@ -21,6 +21,7 @@
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import CourseLocator
from pyquery import PyQuery as pq
+from edx_toggles.toggles.testutils import override_waffle_flag
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
@@ -38,6 +39,7 @@
from common.djangoapps.student.views import complete_course_mode_info
from common.djangoapps.util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from common.djangoapps.util.testing import EventTestMixin
+from common.djangoapps.student.toggles import ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.verify_student.tests import TestVerificationBase
@@ -47,6 +49,7 @@
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
+from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from xmodule.modulestore.tests.django_utils import ModuleStoreEnum, ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
@@ -284,6 +287,11 @@ def setUp(self):
self.client = Client()
cache.clear()
+ patch_context = patch('common.djangoapps.student.helpers.get_course_dates_for_email')
+ get_course = patch_context.start()
+ get_course.return_value = []
+ self.addCleanup(patch_context.stop)
+
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def _check_verification_status_on(self, mode, value):
"""
@@ -651,7 +659,7 @@ def assert_enrollment_mode_change_event_was_emitted(self, user, course_key, mode
)
self.mock_segment_tracker.reset_mock()
- def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment):
+ def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment, course_run=None):
"""Ensures an enrollment event was emitted since the last event related assertion"""
self.mock_tracker.emit.assert_called_once_with(
'edx.course.enrollment.activated',
@@ -662,7 +670,8 @@ def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollme
}
)
self.mock_tracker.reset_mock()
- properties, traits = self._build_segment_properties_and_traits(user, course_key, course, enrollment, True)
+ properties, traits = self._build_segment_properties_and_traits(user, course_key, course,
+ enrollment, True, course_run)
self.mock_segment_tracker.track.assert_called_once_with(
user.id, 'edx.course.enrollment.activated', properties, traits=traits
)
@@ -685,7 +694,8 @@ def assert_unenrollment_event_was_emitted(self, user, course_key, course, enroll
)
self.mock_segment_tracker.reset_mock()
- def _build_segment_properties_and_traits(self, user, course_key, course, enrollment, activated=False):
+ def _build_segment_properties_and_traits(self, user, course_key, course, enrollment,
+ activated=False, course_run=None):
""" Builds the segment properties and traits that are sent during enrollment events """
properties = {
'category': 'conversion',
@@ -698,6 +708,10 @@ def _build_segment_properties_and_traits(self, user, course_key, course, enrollm
traits = properties.copy()
traits.update({'course_title': course.display_name, 'email': user.email})
+ lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
+ learning_base_url = configuration_helpers.get_value('LEARNING_MICROFRONTEND_URL',
+ settings.LEARNING_MICROFRONTEND_URL)
+ studio_request = settings.ROOT_URLCONF == 'cms.urls'
if activated:
properties.update({
'email': user.email,
@@ -707,19 +721,78 @@ def _build_segment_properties_and_traits(self, user, course_key, course, enrollm
'course_start': course.start,
'course_pacing': course.pacing,
'redesign_email': False,
+ 'studio_request': studio_request,
})
+ if not studio_request:
+ properties.update({
+ 'price': 'Free',
+ 'goals_enabled': False,
+ 'learner_name': user.profile.name,
+ 'course_run_key': str(course_key),
+ 'lms_base_url': lms_root_url,
+ 'learning_base_url': learning_base_url,
+ 'course_title': course_run.get('title'),
+ 'short_description': course_run.get('short_description'),
+ 'marketing_url': course_run.get('marketing_url'),
+ 'pacing_type': course_run.get('pacing_type'),
+ 'partner_image_url': '',
+ 'banner_image_url': course_run.get('image').get('src'),
+ 'instructors': [],
+ 'instructors_count': 'even',
+ 'min_effort': course_run.get('min_effort'),
+ 'max_effort': course_run.get('max_effort'),
+ 'weeks_to_complete': course_run.get('weeks_to_complete'),
+ 'learners_count': '{:,}'.format(course_run.get('enrollment_count')),
+ 'course_date_blocks': [],
+ })
+
return properties, traits
-class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase):
+@override_waffle_flag(ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN, active=True)
+@override_settings(PAID_COURSE_REGISTRATION_CURRENCY=["USD", "$"])
+class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase, ModuleStoreTestCase):
"""Tests enrolling and unenrolling in courses."""
+ def setUp(self):
+ """
+ Set up tests
+ """
+ super().setUp()
+
+ patch_context = patch('common.djangoapps.student.helpers.get_course_dates_for_email')
+ get_course = patch_context.start()
+ get_course.return_value = []
+ self.addCleanup(patch_context.stop)
+
+ @staticmethod
+ def _create_course_run(course_id, course):
+ """
+ Discovery course run
+ """
+ course_run = CourseRunFactory.create(key=course_id)
+ course_run.update({
+ 'title': course.display_name,
+ 'short_description': course.short_description,
+ 'marketing_url': course.marketing_url,
+ 'pacing_type': 'self_paced' if course.self_paced else 'instructor_paced',
+ 'banner_image_url': course.banner_image_url,
+ 'min_effort': 1,
+ 'enrollment_count': 12345
+ })
+ return course_run
+
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_enrollment(self):
user = UserFactory.create(username="joe", email="joe@joe.com", password="password")
course_id = CourseKey.from_string("edX/Test101/2013")
course_id_partial = CourseKey.from_string("edX/Test101/")
course = CourseOverviewFactory.create(id=course_id)
+ course_run = self._create_course_run(course_id, course)
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
# Test basic enrollment
assert not CourseEnrollment.is_enrolled(user, course_id)
@@ -727,7 +800,7 @@ def test_enrollment(self):
enrollment = CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
assert CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)
- self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
+ self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run)
# Enrolling them again should be harmless
enrollment = CourseEnrollment.enroll(user, course_id)
@@ -761,12 +834,19 @@ def test_enrollment(self):
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
assert CourseEnrollment.is_enrolled(user, course_id)
assert enrollment.mode == 'audit'
+ self.addCleanup(patch_course_data.stop)
+ @override_settings(LEARNING_MICROFRONTEND_URL='https://learningmfe.openedx.org')
def test_enrollment_non_existent_user(self):
# Testing enrollment of newly unsaved user (i.e. no database entry)
user = UserFactory(username="rusty", email="rusty@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
+ course_run = self._create_course_run(course_id, course)
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
assert not CourseEnrollment.is_enrolled(user, course_id)
@@ -778,17 +858,23 @@ def test_enrollment_non_existent_user(self):
# should still work
enrollment = CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
- self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
+ self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run)
+ self.addCleanup(patch_course_data.stop)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_enrollment_by_email(self):
user = UserFactory.create(username="jack", email="jack@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
+ course_run = self._create_course_run(course_id, course)
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
enrollment = CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
- self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
+ self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run)
# This won't throw an exception, even though the user is not found
assert CourseEnrollment.enroll_by_email('not_jack@fake.edx.org', course_id) is None
@@ -816,6 +902,7 @@ def test_enrollment_by_email(self):
# Unenroll on non-existent user shouldn't throw an error
CourseEnrollment.unenroll_by_email("not_jack@fake.edx.org", course_id)
self.assert_no_events_were_emitted()
+ self.addCleanup(patch_course_data.stop)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
def test_enrollment_multiple_classes(self):
@@ -824,11 +911,24 @@ def test_enrollment_multiple_classes(self):
course_id2 = CourseLocator("MITx", "6.003z", "2012")
course1 = CourseOverviewFactory.create(id=course_id1)
course2 = CourseOverviewFactory.create(id=course_id2)
+ course_run1 = self._create_course_run(course_id1, course1)
+ course_run2 = self._create_course_run(course_id2, course2)
+
+ patch_course_data1 = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data1 = patch_course_data1.start()
+ course_data1.return_value = course_run1
enrollment1 = CourseEnrollment.enroll(user, course_id1)
- self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1)
+ self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1, course_run1)
+ self.addCleanup(course_data1.stop)
+
+ patch_course_data2 = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data2 = patch_course_data2.start()
+ course_data2.return_value = course_run2
+
enrollment2 = CourseEnrollment.enroll(user, course_id2)
- self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2)
+ self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2, course_run2)
+ self.addCleanup(course_data2.stop)
assert CourseEnrollment.is_enrolled(user, course_id1)
assert CourseEnrollment.is_enrolled(user, course_id2)
@@ -847,6 +947,11 @@ def test_activation(self):
user = UserFactory.create(username="jack", email="jack@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
+ course_run = self._create_course_run(course_id, course)
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
assert not CourseEnrollment.is_enrolled(user, course_id)
# Creating an enrollment doesn't actually enroll a student
@@ -858,7 +963,7 @@ def test_activation(self):
# Until you explicitly activate it
enrollment.activate()
assert CourseEnrollment.is_enrolled(user, course_id)
- self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
+ self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run)
# Activating something that's already active does nothing
enrollment.activate()
@@ -879,15 +984,22 @@ def test_activation(self):
# for that user/course_id combination
CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
- self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
+ self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run)
+ self.addCleanup(course_data.stop)
+ @override_settings(LEARNING_MICROFRONTEND_URL='https://learningmfe.openedx.org')
def test_change_enrollment_modes(self):
user = UserFactory.create(username="justin", email="jh@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
+ course_run = self._create_course_run(course_id, course)
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
- self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
+ self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment, course_run)
enrollment = CourseEnrollment.enroll(user, course_id, "honor")
self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "honor", course, enrollment)
@@ -898,6 +1010,7 @@ def test_change_enrollment_modes(self):
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "audit", course, enrollment)
+ self.addCleanup(course_data.stop)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
diff --git a/common/djangoapps/student/toggles.py b/common/djangoapps/student/toggles.py
index 9c50bd6290a0..6f18354af43b 100644
--- a/common/djangoapps/student/toggles.py
+++ b/common/djangoapps/student/toggles.py
@@ -3,7 +3,6 @@
"""
from edx_toggles.toggles import WaffleFlag
-
# Namespace for student waffle flags.
WAFFLE_FLAG_NAMESPACE = 'student'
@@ -22,3 +21,22 @@
def should_show_amplitude_recommendations():
return ENABLE_AMPLITUDE_RECOMMENDATIONS.is_enabled()
+
+
+# Waffle flag to enable redesigned course enrollment confirmation email.
+# .. toggle_name: student.enable_redesign_enrollment_confirmation_email
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Enable redesign email template only for staff users for testing.
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2022-08-05
+# .. toggle_target_removal_date: None
+# .. toggle_warning: None
+# .. toggle_tickets: VAN-1064
+ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN = WaffleFlag(
+ f'{WAFFLE_FLAG_NAMESPACE}.enable_redesign_enrollment_confirmation_email', __name__
+)
+
+
+def should_send_redesign_email():
+ return ENROLLMENT_CONFIRMATION_EMAIL_REDESIGN.is_enabled()
diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py
index d131575193b4..d04a6d492db5 100644
--- a/common/djangoapps/student/views/management.py
+++ b/common/djangoapps/student/views/management.py
@@ -387,7 +387,8 @@ def change_enrollment(request, check_access=True):
try:
enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes)
if enroll_mode:
- CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode)
+ CourseEnrollment.enroll(user, course_id, check_access=check_access,
+ mode=enroll_mode, request=request)
except Exception: # pylint: disable=broad-except
return HttpResponseBadRequest(_("Could not enroll"))
diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py
index 65abd5ad9287..4e8541edf3e9 100644
--- a/common/djangoapps/third_party_auth/tests/specs/base.py
+++ b/common/djangoapps/third_party_auth/tests/specs/base.py
@@ -149,7 +149,7 @@ def assert_json_failure_response_is_username_collision(self, response):
assert 409 == response.status_code
payload = json.loads(response.content.decode('utf-8'))
assert not payload.get('success')
- assert 'belongs to an existing account' in payload['username'][0]['user_message']
+ assert 'It looks like this username is already taken' == payload['username'][0]['user_message']
def assert_json_success_response_looks_correct(self, response, verify_redirect_url):
"""Asserts the json response indicates success and redirection."""
diff --git a/common/djangoapps/track/views/__init__.py b/common/djangoapps/track/views/__init__.py
index c5241b23fb4a..140afbd0e640 100644
--- a/common/djangoapps/track/views/__init__.py
+++ b/common/djangoapps/track/views/__init__.py
@@ -102,13 +102,12 @@ def user_track(request):
# Remove it after the experiment has been paused.
if (
name == 'edx.course.home.resume_course.clicked' and
- data.get('from_email') and
data.get('event_type') == 'start' and
request.user
):
optimizely_client = OptimizelyClient.get_optimizely_client()
if optimizely_client:
- optimizely_client.track('user_start_course_click', str(request.user.id))
+ optimizely_client.track('course_started', str(request.user.id))
return HttpResponse('success')
diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb
index 1a96ea5ac5ed..26b1ccd35fb6 100644
Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ
diff --git a/conf/locale/en/LC_MESSAGES/django.po b/conf/locale/en/LC_MESSAGES/django.po
index 101d44c7bedb..6bee1ff73710 100644
--- a/conf/locale/en/LC_MESSAGES/django.po
+++ b/conf/locale/en/LC_MESSAGES/django.po
@@ -38,8 +38,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2022-08-28 20:34+0000\n"
-"PO-Revision-Date: 2022-08-28 20:34:49.087264\n"
+"POT-Creation-Date: 2022-09-04 20:34+0000\n"
+"PO-Revision-Date: 2022-09-04 20:34:49.138307\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: en\n"
@@ -15551,6 +15551,12 @@ msgstr ""
msgid "Discussion ID: {discussion_id}"
msgstr ""
+#: lms/templates/discussion/_discussion_inline_studio.html
+msgid ""
+"The discussion block is disabled for this course as it is not using a "
+"compatible discussion provider."
+msgstr ""
+
#: lms/templates/discussion/_filter_dropdown.html
msgid "Discussion topics list"
msgstr ""
diff --git a/conf/locale/en/LC_MESSAGES/djangojs.po b/conf/locale/en/LC_MESSAGES/djangojs.po
index 392532a6b306..be8070e0eafa 100644
--- a/conf/locale/en/LC_MESSAGES/djangojs.po
+++ b/conf/locale/en/LC_MESSAGES/djangojs.po
@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2022-08-28 20:34+0000\n"
-"PO-Revision-Date: 2022-08-28 20:34:49.099134\n"
+"POT-Creation-Date: 2022-09-04 20:34+0000\n"
+"PO-Revision-Date: 2022-09-04 20:34:49.012097\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: en\n"
diff --git a/conf/locale/eo/LC_MESSAGES/django.mo b/conf/locale/eo/LC_MESSAGES/django.mo
index f510f64f8a19..83a1fc630c1a 100644
Binary files a/conf/locale/eo/LC_MESSAGES/django.mo and b/conf/locale/eo/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po
index aae45bdca44c..f40686fa1e3d 100644
--- a/conf/locale/eo/LC_MESSAGES/django.po
+++ b/conf/locale/eo/LC_MESSAGES/django.po
@@ -38,8 +38,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2022-08-28 20:34+0000\n"
-"PO-Revision-Date: 2022-08-28 20:34:49.087264\n"
+"POT-Creation-Date: 2022-09-04 20:34+0000\n"
+"PO-Revision-Date: 2022-09-04 20:34:49.138307\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: eo\n"
@@ -20011,6 +20011,14 @@ msgstr ""
msgid "Discussion ID: {discussion_id}"
msgstr "Dïsçüssïön ÌD: {discussion_id} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт#"
+#: lms/templates/discussion/_discussion_inline_studio.html
+msgid ""
+"The discussion block is disabled for this course as it is not using a "
+"compatible discussion provider."
+msgstr ""
+"Thé dïsçüssïön ßlöçk ïs dïsäßléd för thïs çöürsé äs ït ïs nöt üsïng ä "
+"çömpätïßlé dïsçüssïön prövïdér. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмє#"
+
#: lms/templates/discussion/_filter_dropdown.html
msgid "Discussion topics list"
msgstr "Dïsçüssïön töpïçs lïst Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#"
diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.mo b/conf/locale/eo/LC_MESSAGES/djangojs.mo
index 80b48720cc14..5a7ba1ed6dd3 100644
Binary files a/conf/locale/eo/LC_MESSAGES/djangojs.mo and b/conf/locale/eo/LC_MESSAGES/djangojs.mo differ
diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.po b/conf/locale/eo/LC_MESSAGES/djangojs.po
index 4627eb3d9563..95c9a132816d 100644
--- a/conf/locale/eo/LC_MESSAGES/djangojs.po
+++ b/conf/locale/eo/LC_MESSAGES/djangojs.po
@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2022-08-28 20:34+0000\n"
-"PO-Revision-Date: 2022-08-28 20:34:49.099134\n"
+"POT-Creation-Date: 2022-09-04 20:34+0000\n"
+"PO-Revision-Date: 2022-09-04 20:34:49.012097\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: eo\n"
diff --git a/conf/locale/fr/LC_MESSAGES/django.po b/conf/locale/fr/LC_MESSAGES/django.po
index 58c2ffb033e0..36a9c2356123 100644
--- a/conf/locale/fr/LC_MESSAGES/django.po
+++ b/conf/locale/fr/LC_MESSAGES/django.po
@@ -97,6 +97,7 @@
# This file is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.
#
# Translators:
+# alexis swyngedauw, 2022
# 0169fee580ff5de3f9b7241d14f30af9_5f30934 <1948a2319336319ed4429b6139c8c1c2_916898>, 2020
# Aurelien Croq , 2017
# Bertrand Marron , 2014
diff --git a/conf/locale/fr/LC_MESSAGES/djangojs.po b/conf/locale/fr/LC_MESSAGES/djangojs.po
index d5d81aa9ba5a..4cc316677cbe 100644
--- a/conf/locale/fr/LC_MESSAGES/djangojs.po
+++ b/conf/locale/fr/LC_MESSAGES/djangojs.po
@@ -6,6 +6,7 @@
# Translators:
# Abdessamad Derraz , 2021
# SALHI Aissam , 2014
+# alexis swyngedauw, 2022
# anaseha , 2014
# anaseha , 2014
# Aurélien Croq , 2017
@@ -153,6 +154,7 @@
#
# Translators:
# Alexandre DS , 2020
+# alexis swyngedauw, 2022
# 0169fee580ff5de3f9b7241d14f30af9_5f30934 <1948a2319336319ed4429b6139c8c1c2_916898>, 2020
# ASSYASS Mahmoud , 2015
# Aurélien Croq , 2017
diff --git a/conf/locale/rtl/LC_MESSAGES/django.mo b/conf/locale/rtl/LC_MESSAGES/django.mo
index e6b44ec1dcbe..6bdb69fe7120 100644
Binary files a/conf/locale/rtl/LC_MESSAGES/django.mo and b/conf/locale/rtl/LC_MESSAGES/django.mo differ
diff --git a/conf/locale/rtl/LC_MESSAGES/django.po b/conf/locale/rtl/LC_MESSAGES/django.po
index 561f0e58f35d..fe8c6062a1a4 100644
--- a/conf/locale/rtl/LC_MESSAGES/django.po
+++ b/conf/locale/rtl/LC_MESSAGES/django.po
@@ -38,8 +38,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2022-08-28 20:34+0000\n"
-"PO-Revision-Date: 2022-08-28 20:34:49.087264\n"
+"POT-Creation-Date: 2022-09-04 20:34+0000\n"
+"PO-Revision-Date: 2022-09-04 20:34:49.138307\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: rtl\n"
@@ -17392,6 +17392,14 @@ msgstr ""
msgid "Discussion ID: {discussion_id}"
msgstr "Đᴉsɔnssᴉøn ƗĐ: {discussion_id}"
+#: lms/templates/discussion/_discussion_inline_studio.html
+msgid ""
+"The discussion block is disabled for this course as it is not using a "
+"compatible discussion provider."
+msgstr ""
+"Ŧɥǝ dᴉsɔnssᴉøn bløɔʞ ᴉs dᴉsɐblǝd ɟøɹ ʇɥᴉs ɔønɹsǝ ɐs ᴉʇ ᴉs nøʇ nsᴉnƃ ɐ "
+"ɔøɯdɐʇᴉblǝ dᴉsɔnssᴉøn dɹøʌᴉdǝɹ."
+
#: lms/templates/discussion/_filter_dropdown.html
msgid "Discussion topics list"
msgstr "Đᴉsɔnssᴉøn ʇødᴉɔs lᴉsʇ"
diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.mo b/conf/locale/rtl/LC_MESSAGES/djangojs.mo
index b83152fcad6a..18f28b38633d 100644
Binary files a/conf/locale/rtl/LC_MESSAGES/djangojs.mo and b/conf/locale/rtl/LC_MESSAGES/djangojs.mo differ
diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.po b/conf/locale/rtl/LC_MESSAGES/djangojs.po
index 739a77da3b4c..c5c1714b7843 100644
--- a/conf/locale/rtl/LC_MESSAGES/djangojs.po
+++ b/conf/locale/rtl/LC_MESSAGES/djangojs.po
@@ -32,8 +32,8 @@ msgid ""
msgstr ""
"Project-Id-Version: 0.1a\n"
"Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n"
-"POT-Creation-Date: 2022-08-28 20:34+0000\n"
-"PO-Revision-Date: 2022-08-28 20:34:49.099134\n"
+"POT-Creation-Date: 2022-09-04 20:34+0000\n"
+"PO-Revision-Date: 2022-09-04 20:34:49.012097\n"
"Last-Translator: \n"
"Language-Team: openedx-translation \n"
"Language: rtl\n"
diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py
index a4af227fe827..4215dff245c7 100644
--- a/lms/djangoapps/ccx/tests/test_field_override_performance.py
+++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py
@@ -76,6 +76,11 @@ def setUp(self):
self.course = None
self.ccx = None
+ patch_context = mock.patch('common.djangoapps.student.helpers.get_course_dates_for_email')
+ get_course = patch_context.start()
+ get_course.return_value = []
+ self.addCleanup(patch_context.stop)
+
def setup_course(self, size, enable_ccx, view_as_ccx):
"""
Build a gradable course where each node has `size` children.
diff --git a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py
index 3e58ecd6464d..ea8ba99c16e9 100644
--- a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py
+++ b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py
@@ -5,18 +5,22 @@
import ddt
import mock
from django.urls import reverse
-
from edx_toggles.toggles.testutils import override_waffle_flag
+
from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.roles import CourseInstructorRole
+from common.djangoapps.student.tests.factories import UserFactory
+from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests
from lms.djangoapps.courseware.toggles import (
+ COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT,
COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES,
- COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION,
+ COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION
+)
+from openedx.features.enterprise_support.tests.factories import (
+ EnterpriseCourseEnrollmentFactory,
+ EnterpriseCustomerUserFactory
)
-from common.djangoapps.student.models import CourseEnrollment
-from common.djangoapps.student.tests.factories import UserFactory
-from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests
-from lms.djangoapps.courseware.toggles import COURSEWARE_MFE_MILESTONES_STREAK_DISCOUNT
@ddt.ddt
@@ -82,6 +86,16 @@ def test_get_unknown_course(self):
response = self.client.get(url)
assert response.status_code == 404
+ def _assert_course_access_response(self, response, expect_course_access, expected_error_code):
+ """
+ Responsible to asset the course_access response with expected values.
+ """
+ assert response.status_code == 200
+ assert response.data['course_access']['has_access'] == expect_course_access
+ assert response.data['course_access']['error_code'] == expected_error_code
+ # Start date is used when handling some errors, so make sure it is present too
+ assert response.data['start'] == self.course.start.isoformat() + 'Z'
+
def test_streak_data_in_response(self):
""" Test that metadata endpoint returns data for the streak celebration """
CourseEnrollment.enroll(self.user, self.course.id, 'audit')
@@ -138,6 +152,15 @@ def test_streak_data_in_response(self):
'dsc_required': True,
'expect_course_access': False,
'error_code': 'data_sharing_access_required'
+ },
+ {
+ # Data sharing Consent required staff should Not have access.
+ 'enroll_user': True,
+ 'instructor_role': True,
+ 'masquerade_role': None,
+ 'dsc_required': True,
+ 'expect_course_access': False,
+ 'error_code': 'data_sharing_access_required'
}
)
@ddt.unpack
@@ -159,8 +182,43 @@ def test_course_access(
with mock.patch('openedx.features.enterprise_support.api.get_enterprise_consent_url', return_value=consent_url):
response = self.client.get(self.url)
- assert response.status_code == 200
- assert response.data['course_access']['has_access'] == expect_course_access
- assert response.data['course_access']['error_code'] == error_code
- # Start date is used when handling some errors, so make sure it is present too
- assert response.data['start'] == self.course.start.isoformat() + 'Z'
+ self._assert_course_access_response(response, expect_course_access, error_code)
+
+ @ddt.data(True, False)
+ def test_course_access_with_correct_active_enterprise(self, instructor_role):
+ """
+ Test that course_access is calculated correctly based on
+ access to MFE and access to the course itself.
+ """
+ if instructor_role:
+ CourseInstructorRole(self.course.id).add_users(self.user)
+
+ # Test with no EnterpriseCourseEnrollment
+ course_enrollment = CourseEnrollment.enroll(self.user, self.course.id, 'audit')
+ response = self.client.get(self.url)
+ self._assert_course_access_response(response, True, None)
+
+ # Test with EnterpriseCourseEnrollment and having correct active enterprise
+ course = course_enrollment.course
+ enterprise_customer_user = EnterpriseCustomerUserFactory(user_id=self.user.id)
+ EnterpriseCourseEnrollmentFactory(enterprise_customer_user=enterprise_customer_user, course_id=course.id)
+ response = self.client.get(self.url)
+ self._assert_course_access_response(response, True, None)
+
+ # Test with incorrect active enterprise
+ enterprise_customer_user_2 = EnterpriseCustomerUserFactory(user_id=self.user.id, active=True)
+ enterprise_customer_user.refresh_from_db()
+ assert not enterprise_customer_user.active
+ assert enterprise_customer_user_2.active
+ response = self.client.get(self.url)
+ self._assert_course_access_response(response, False, 'incorrect_active_enterprise')
+
+ # test when no active enterprise at all (ideally this should never happen)
+ enterprise_customer_user_2.active = False
+ enterprise_customer_user_2.save()
+ enterprise_customer_user.refresh_from_db()
+ enterprise_customer_user_2.refresh_from_db()
+ assert not enterprise_customer_user.active
+ assert not enterprise_customer_user_2.active
+ response = self.client.get(self.url)
+ self._assert_course_access_response(response, False, 'incorrect_active_enterprise')
diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py
index 23de19fe5643..e534d03ebbf7 100644
--- a/lms/djangoapps/course_home_api/course_metadata/views.py
+++ b/lms/djangoapps/course_home_api/course_metadata/views.py
@@ -88,7 +88,7 @@ def get(self, request, *args, **kwargs):
'load',
check_if_enrolled=True,
check_if_authenticated=True,
- check_if_dsc_required=True,
+ apply_enterprise_checks=True,
)
_, request.user = setup_masquerade(
diff --git a/lms/djangoapps/course_home_api/dates/serializers.py b/lms/djangoapps/course_home_api/dates/serializers.py
index b4cd85e8879b..1c7b93b8341e 100644
--- a/lms/djangoapps/course_home_api/dates/serializers.py
+++ b/lms/djangoapps/course_home_api/dates/serializers.py
@@ -36,8 +36,7 @@ def get_learner_has_access(self, block):
def get_link(self, block):
if block.link:
- request = self.context.get('request')
- return request.build_absolute_uri(block.link)
+ return block.link
return ''
def get_first_component_block_id(self, block):
diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py
index dc8b0fe31d99..9d9e4e1cd161 100644
--- a/lms/djangoapps/course_home_api/outline/views.py
+++ b/lms/djangoapps/course_home_api/outline/views.py
@@ -395,7 +395,7 @@ def save_course_goal(request): # pylint: disable=missing-function-docstring
# Remove it after the experiment has been paused.
optimizely_client = OptimizelyClient.get_optimizely_client()
if optimizely_client and request.user:
- optimizely_client.track('user_goal_setting_click', str(request.user.id))
+ optimizely_client.track('goal_set', str(request.user.id))
return Response({
'header': _('Your course goal has been successfully set.'),
'message': _('Course goal updated successfully.'),
diff --git a/lms/djangoapps/courseware/access_response.py b/lms/djangoapps/courseware/access_response.py
index 9885b4169f22..abfdf61db2c6 100644
--- a/lms/djangoapps/courseware/access_response.py
+++ b/lms/djangoapps/courseware/access_response.py
@@ -227,6 +227,22 @@ def __init__(self):
super().__init__(error_code, developer_message, user_message)
+class IncorrectActiveEnterpriseAccessError(AccessError):
+ """
+ Access denied because the user must login with correct enterprise.
+ """
+ def __init__(self, enrollment_enterprise_name, active_enterprise_name):
+ error_code = "incorrect_active_enterprise"
+ developer_message = "User active enterprise should be same as EnterpriseCourseEnrollment enterprise."
+ user_message = _("You are enrolled in this course with '{enrollment_enterprise_name}'. However, you are "
+ "currently logged in as a '{active_enterprise_name}' user. Please log in with "
+ "'{enrollment_enterprise_name}' to access this course.")
+ user_message = user_message.format(
+ enrollment_enterprise_name=enrollment_enterprise_name, active_enterprise_name=active_enterprise_name
+ )
+ super().__init__(error_code, developer_message, user_message)
+
+
class DataSharingConsentRequiredAccessError(AccessError):
"""
Access denied because the user must give Data sharing consent before access it.
diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py
index ab82296716f9..18f4bb27a8a1 100644
--- a/lms/djangoapps/courseware/access_utils.py
+++ b/lms/djangoapps/courseware/access_utils.py
@@ -9,6 +9,7 @@
from crum import get_current_request
from django.conf import settings
+from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomerUser
from pytz import UTC
from common.djangoapps.student.models import CourseEnrollment
@@ -18,6 +19,7 @@
AuthenticationRequiredAccessError,
DataSharingConsentRequiredAccessError,
EnrollmentRequiredAccessError,
+ IncorrectActiveEnterpriseAccessError,
StartDateError
)
from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student
@@ -178,7 +180,7 @@ def check_data_sharing_consent(course_id):
from openedx.features.enterprise_support.api import get_enterprise_consent_url
consent_url = get_enterprise_consent_url(
request=get_current_request(),
- course_id=course_id,
+ course_id=str(course_id),
return_to='courseware',
enrollment_exists=True,
source='CoursewareAccess'
@@ -186,3 +188,47 @@ def check_data_sharing_consent(course_id):
if consent_url:
return DataSharingConsentRequiredAccessError(consent_url=consent_url)
return ACCESS_GRANTED
+
+
+def check_correct_active_enterprise_customer(user, course_id):
+ """
+ Grants access if the user's active enterprise customer is same as EnterpriseCourseEnrollment's Enterprise.
+ Also, Grant access if enrollment is not Enterprise
+
+ Returns:
+ AccessResponse: Either ACCESS_GRANTED or IncorrectActiveEnterpriseAccessError
+ """
+ enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter(
+ course_id=course_id, enterprise_customer_user__user_id=user.id
+ )
+ if not enterprise_enrollments.exists():
+ return ACCESS_GRANTED
+
+ try:
+ active_enterprise_customer_user = EnterpriseCustomerUser.objects.get(user_id=user.id, active=True)
+ if enterprise_enrollments.filter(enterprise_customer_user=active_enterprise_customer_user).exists():
+ return ACCESS_GRANTED
+
+ active_enterprise_name = active_enterprise_customer_user.enterprise_customer.name
+ except (EnterpriseCustomerUser.DoesNotExist, EnterpriseCustomerUser.MultipleObjectsReturned):
+ # Ideally this should not happen. As there should be only 1 active enterprise customer in our system
+ log.error("Multiple or No Active Enterprise found for the user %s.", user.id)
+ active_enterprise_name = 'Incorrect'
+
+ enrollment_enterprise_name = enterprise_enrollments.first().enterprise_customer_user.enterprise_customer.name
+ return IncorrectActiveEnterpriseAccessError(enrollment_enterprise_name, active_enterprise_name)
+
+
+def is_priority_access_error(access_error):
+ """
+ Check if given access error is a priority Access Error or not.
+ Priority Access Error can not be bypassed by staff users.
+ """
+ priority_access_errors = [
+ DataSharingConsentRequiredAccessError,
+ IncorrectActiveEnterpriseAccessError,
+ ]
+ for priority_access_error in priority_access_errors:
+ if isinstance(access_error, priority_access_error):
+ return True
+ return False
diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py
index b46aaba2ba53..c7a9c981a516 100644
--- a/lms/djangoapps/courseware/courses.py
+++ b/lms/djangoapps/courseware/courses.py
@@ -7,6 +7,7 @@
from collections import defaultdict, namedtuple
from datetime import datetime
+import six
import pytz
from crum import get_current_request
from dateutil.parser import parse as parse_date
@@ -32,7 +33,8 @@
OldMongoAccessError,
StartDateError
)
-from lms.djangoapps.courseware.access_utils import check_authentication, check_data_sharing_consent, check_enrollment
+from lms.djangoapps.courseware.access_utils import check_authentication, check_data_sharing_consent, check_enrollment, \
+ check_correct_active_enterprise_customer, is_priority_access_error
from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException
from lms.djangoapps.courseware.date_summary import (
CertificateAvailableDate,
@@ -137,7 +139,7 @@ def check_course_access(
check_if_enrolled=False,
check_survey_complete=True,
check_if_authenticated=False,
- check_if_dsc_required=False,
+ apply_enterprise_checks=False,
):
"""
Check that the user has the access to perform the specified action
@@ -164,8 +166,12 @@ def _check_nonstaff_access():
if not enrollment_access_response:
return enrollment_access_response
- if check_if_dsc_required:
- data_sharing_consent_response = check_data_sharing_consent(course)
+ if apply_enterprise_checks:
+ correct_active_enterprise_response = check_correct_active_enterprise_customer(user, course.id)
+ if not correct_active_enterprise_response:
+ return correct_active_enterprise_response
+
+ data_sharing_consent_response = check_data_sharing_consent(course.id)
if not data_sharing_consent_response:
return data_sharing_consent_response
@@ -178,15 +184,18 @@ def _check_nonstaff_access():
# This access_response will be ACCESS_GRANTED
return access_response
+ non_staff_access_response = _check_nonstaff_access()
+
+ # User has course access OR access error is a priority error
+ if non_staff_access_response or is_priority_access_error(non_staff_access_response):
+ return non_staff_access_response
+
# Allow staff full access to the course even if other checks fail
- nonstaff_access_response = _check_nonstaff_access()
- if not nonstaff_access_response:
- staff_access_response = has_access(user, 'staff', course.id)
- if staff_access_response:
- return staff_access_response
+ staff_access_response = has_access(user, 'staff', course.id)
+ if staff_access_response:
+ return staff_access_response
- # This access_response will be ACCESS_GRANTED
- return nonstaff_access_response
+ return non_staff_access_response
def check_course_access_with_redirect(course, user, action, check_if_enrolled=False, check_survey_complete=True, check_if_authenticated=False): # lint-amnesty, pylint: disable=line-too-long
@@ -494,6 +503,28 @@ def date_block_key_fn(block):
return block.date or datetime.max.replace(tzinfo=pytz.UTC)
+def _get_absolute_url(request, url_path):
+ """Construct an absolute URL back to the site.
+
+ Arguments:
+ request (request): request object.
+ url_path (string): The path of the URL.
+
+ Returns:
+ URL
+
+ """
+ if not url_path:
+ return ''
+
+ if request:
+ return request.build_absolute_uri(url_path)
+
+ site_name = configuration_helpers.get_value('SITE_NAME', settings.SITE_NAME)
+ parts = ("https" if settings.HTTPS == "on" else "http", site_name, url_path, '', '', '')
+ return six.moves.urllib.parse.urlunparse(parts)
+
+
def get_course_assignment_date_blocks(course, user, request, num_return=None,
include_past_dates=False, include_access=False):
"""
@@ -510,7 +541,7 @@ def get_course_assignment_date_blocks(course, user, request, num_return=None,
date_block.complete = assignment.complete
date_block.assignment_type = assignment.assignment_type
date_block.past_due = assignment.past_due
- date_block.link = request.build_absolute_uri(assignment.url) if assignment.url else ''
+ date_block.link = _get_absolute_url(request, assignment.url)
date_block.set_title(assignment.title, link=assignment.url)
date_block._extra_info = assignment.extra_info # pylint: disable=protected-access
date_blocks.append(date_block)
diff --git a/lms/djangoapps/courseware/tests/test_discussion_xblock.py b/lms/djangoapps/courseware/tests/test_discussion_xblock.py
index 0b3577c34f9e..e5ee524ec8a7 100644
--- a/lms/djangoapps/courseware/tests/test_discussion_xblock.py
+++ b/lms/djangoapps/courseware/tests/test_discussion_xblock.py
@@ -24,6 +24,7 @@
from lms.djangoapps.course_api.blocks.tests.helpers import deserialize_usage_key
from lms.djangoapps.courseware.module_render import get_module_for_descriptor_internal
from lms.djangoapps.courseware.tests.helpers import XModuleRenderingTestBase
+from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
@@ -161,7 +162,10 @@ def test_studio_view(self):
assert fragment.content == self.template_canary
self.render_template.assert_called_once_with(
'discussion/_discussion_inline_studio.html',
- {'discussion_id': self.discussion_id}
+ {
+ 'discussion_id': self.discussion_id,
+ 'is_visible': True,
+ }
)
@ddt.data(
@@ -374,6 +378,32 @@ def test_discussion_student_view_data(self):
assert block_data['display_name'] == (self.store.get_item(block_key).display_name or '')
assert block_data['student_view_data'] == {'topic_id': self.discussion_id}
+ def test_discussion_xblock_visibility(self):
+ """
+ Tests that the discussion xblock is hidden when discussion provider is openedx
+ """
+ # Enable new OPEN_EDX provider for this course
+ course_key = self.course.location.course_key
+ DiscussionsConfiguration.objects.create(
+ context_key=course_key,
+ enabled=True,
+ provider_type=Provider.OPEN_EDX,
+ )
+
+ discussion_xblock = get_module_for_descriptor_internal(
+ user=self.user,
+ descriptor=self.discussion,
+ student_data=mock.Mock(name='student_data'),
+ course_id=self.course.id,
+ track_function=mock.Mock(name='track_function'),
+ request_token='request_token',
+ )
+
+ fragment = discussion_xblock.render('student_view')
+ html = fragment.content
+ assert 'data-user-create-comment="false"' not in html
+ assert 'data-user-create-subcomment="false"' not in html
+
class TestXBlockQueryLoad(SharedModuleStoreTestCase):
"""
@@ -400,12 +430,14 @@ def test_permissions_query_load(self):
discussion_target='Target Discussion',
))
- # 4 queries are required to do first discussion xblock render:
+ # 6 queries are required to do first discussion xblock render:
# * split_modulestore_django_splitmodulestorecourseindex x2
- # * waffle_utils_waffleorgoverridemodel
+ # * waffle_flag.discussions.enable_new_structure_discussions
+ # * lms_xblock_xblockasidesconfig
# * django_comment_client_role
+ # * DiscussionsConfiguration
- num_queries = 4
+ num_queries = 6
for discussion in discussions:
discussion_xblock = get_module_for_descriptor_internal(
@@ -421,7 +453,9 @@ def test_permissions_query_load(self):
# Permissions are cached, so no queries required for subsequent renders
- num_queries = 0
+ # query to check for provider_type
+ # query to check waffle flag discussions.enable_new_structure_discussions
+ num_queries = 2
html = fragment.content
assert 'data-user-create-comment="false"' in html
diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py
index eefb231141b4..42687d441275 100644
--- a/lms/djangoapps/courseware/tests/test_tabs.py
+++ b/lms/djangoapps/courseware/tests/test_tabs.py
@@ -3,11 +3,13 @@
"""
from unittest.mock import MagicMock, Mock, patch
+import ddt
import pytest
from crum import set_current_request
from django.contrib.auth.models import AnonymousUser
from django.http import Http404
from django.urls import reverse
+from edx_toggles.toggles.testutils import override_waffle_flag
from milestones.tests.utils import MilestonesTestCaseMixin
from lms.djangoapps.courseware.tabs import (
@@ -20,6 +22,8 @@
)
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
from lms.djangoapps.courseware.views.views import StaticCourseTabView, get_static_tab_fragment
+from lms.djangoapps.discussion.toggles import ENABLE_VIEW_MFE_IN_IFRAME, ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE
+from openedx.core.djangoapps.discussions.url_helpers import get_discussions_mfe_url
from openedx.core.djangolib.testing.utils import get_mock_request
from openedx.core.lib.courses import get_course_by_id
from common.djangoapps.student.models import CourseEnrollment
@@ -735,6 +739,7 @@ def test_static_tab(self):
self.check_get_and_set_method_for_key(tab, 'url_slug')
+@ddt.ddt
class DiscussionLinkTestCase(TabTestCase):
"""Test cases for discussion link tab."""
@@ -747,15 +752,6 @@ def setUp(self):
self.tabs_without_discussion = [
]
- @staticmethod
- def _reverse(course):
- """Custom reverse function"""
- def reverse_discussion_link(viewname, args):
- """reverse lookup for discussion link"""
- if viewname == "forum_form_discussion" and args == [str(course.id)]:
- return "default_discussion_link"
- return reverse_discussion_link
-
def check_discussion(
self, tab_list,
expected_discussion_link,
@@ -763,17 +759,19 @@ def check_discussion(
discussion_link_in_course="",
is_staff=True,
is_enrolled=True,
+ in_iframe_flag=True,
):
"""Helper function to verify whether the discussion tab exists and can be displayed"""
- self.course.tabs = tab_list
- self.course.discussion_link = discussion_link_in_course
- discussion_tab = xmodule_tabs.CourseTabList.get_discussion(self.course)
- user = self.create_mock_user(is_staff=is_staff, is_enrolled=is_enrolled)
with patch('common.djangoapps.student.models.CourseEnrollment.is_enrolled') as check_is_enrolled:
- check_is_enrolled.return_value = is_enrolled
- assert ((discussion_tab is not None) and self.is_tab_enabled(discussion_tab, self.course, user) and
- (discussion_tab.link_func(self.course, self._reverse(self.course))
- == expected_discussion_link)) == expected_can_display_value
+ with override_waffle_flag(ENABLE_VIEW_MFE_IN_IFRAME, in_iframe_flag):
+ self.course.tabs = tab_list
+ self.course.discussion_link = discussion_link_in_course
+ discussion_tab = xmodule_tabs.CourseTabList.get_discussion(self.course)
+ user = self.create_mock_user(is_staff=is_staff, is_enrolled=is_enrolled)
+ check_is_enrolled.return_value = is_enrolled
+ assert ((discussion_tab is not None) and self.is_tab_enabled(discussion_tab, self.course, user) and
+ (discussion_tab.link_func(self.course, reverse)
+ == expected_discussion_link)) == expected_can_display_value
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": False})
def test_explicit_discussion_link(self):
@@ -800,7 +798,7 @@ def test_tabs_with_discussion(self):
"""Test a course with a discussion tab configured"""
self.check_discussion(
tab_list=self.tabs_with_discussion,
- expected_discussion_link="default_discussion_link",
+ expected_discussion_link=reverse("forum_form_discussion", args=[str(self.course.id)]),
expected_can_display_value=True,
)
@@ -818,7 +816,7 @@ def test_tabs_enrolled_or_staff(self):
for is_enrolled, is_staff in [(True, False), (False, True)]:
self.check_discussion(
tab_list=self.tabs_with_discussion,
- expected_discussion_link="default_discussion_link",
+ expected_discussion_link=reverse("forum_form_discussion", args=[str(self.course.id)]),
expected_can_display_value=True,
is_enrolled=is_enrolled,
is_staff=is_staff
@@ -829,12 +827,28 @@ def test_tabs_not_enrolled_or_staff(self):
is_enrolled = is_staff = False
self.check_discussion(
tab_list=self.tabs_with_discussion,
- expected_discussion_link="default_discussion_link",
+ expected_discussion_link=reverse("forum_form_discussion", args=[str(self.course.id)]),
expected_can_display_value=False,
is_enrolled=is_enrolled,
is_staff=is_staff
)
+ @ddt.data(True, False)
+ def test_tab_link(self, toggle_enabled):
+ if toggle_enabled:
+ expected_link = reverse("forum_form_discussion", args=[str(self.course.id)])
+ else:
+ expected_link = get_discussions_mfe_url(course_key=self.course.id)
+
+ with self.settings(FEATURES={'ENABLE_DISCUSSION_SERVICE': True}):
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, True):
+ self.check_discussion(
+ tab_list=self.tabs_with_discussion,
+ expected_discussion_link=expected_link,
+ expected_can_display_value=True,
+ in_iframe_flag=toggle_enabled,
+ )
+
class DatesTabTestCase(TabListTestCase):
"""Test cases for dates tab"""
diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py
index 17c87108e2a9..e549e62d3eaa 100644
--- a/lms/djangoapps/courseware/tests/test_views.py
+++ b/lms/djangoapps/courseware/tests/test_views.py
@@ -338,8 +338,10 @@ class IndexQueryTestCase(ModuleStoreTestCase):
"""
NUM_PROBLEMS = 20
- def test_index_query_counts(self):
+ @patch('common.djangoapps.student.helpers.get_course_dates_for_email')
+ def test_index_query_counts(self, mock_course_dates_for_email):
# TODO: decrease query count as part of REVO-28
+ mock_course_dates_for_email.return_value = []
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
with self.store.default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create()
@@ -350,6 +352,20 @@ def test_index_query_counts(self):
for _ in range(self.NUM_PROBLEMS):
ItemFactory.create(category='problem', parent_location=vertical.location)
+ course_run = CourseRunFactory.create(key=course.id)
+ course_run['title'] = course.display_name
+ course_run['short_description'] = None
+ course_run['marketing_url'] = 'www.edx.org'
+ course_run['pacing_type'] = 'self_paced'
+ course_run['banner_image_url'] = ''
+ course_run['min_effort'] = 1
+ course_run['enrollment_count'] = 12345
+
+ patch_course_data = patch('openedx.core.djangoapps.catalog.api.get_course_run_details')
+ course_data = patch_course_data.start()
+ course_data.return_value = course_run
+ self.addCleanup(patch_course_data.stop)
+
self.client.login(username=self.user.username, password=self.user_password)
CourseEnrollment.enroll(self.user, course.id)
diff --git a/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py b/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py
index 63bf1e7be1ab..3a09e6cdda2b 100644
--- a/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py
+++ b/lms/djangoapps/discussion/django_comment_client/tests/test_utils.py
@@ -27,7 +27,6 @@
from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory
from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin
from lms.djangoapps.discussion.django_comment_client.tests.utils import config_course_discussions, topic_name_to_id
-from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE
from lms.djangoapps.teams.tests.factories import CourseTeamFactory
from openedx.core.djangoapps.course_groups import cohorts
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
diff --git a/lms/djangoapps/discussion/plugins.py b/lms/djangoapps/discussion/plugins.py
index b97cd49e5dd9..df056f7fbb94 100644
--- a/lms/djangoapps/discussion/plugins.py
+++ b/lms/djangoapps/discussion/plugins.py
@@ -4,7 +4,12 @@
from django.conf import settings
+from django.urls import reverse
from django.utils.translation import gettext_noop
+
+
+from lms.djangoapps.discussion.toggles import ENABLE_VIEW_MFE_IN_IFRAME, ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE
+from openedx.core.djangoapps.discussions.url_helpers import get_discussions_mfe_url
from xmodule.tabs import TabFragmentViewMixin
import lms.djangoapps.discussion.django_comment_client.utils as utils
@@ -35,3 +40,12 @@ def is_enabled(cls, course, user=None):
if DiscussionLtiCourseTab.is_enabled(course, user):
return False
return utils.is_discussion_enabled(course.id)
+
+ @property
+ def link_func(self):
+ def _link_func(course, reverse_func):
+ if not ENABLE_VIEW_MFE_IN_IFRAME.is_enabled() and ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(course.id):
+ return get_discussions_mfe_url(course_key=course.id)
+ return reverse('forum_form_discussion', args=[str(course.id)])
+
+ return _link_func
diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py
index 8ebaa9bfce24..403ed55a82cf 100644
--- a/lms/djangoapps/discussion/rest_api/api.py
+++ b/lms/djangoapps/discussion/rest_api/api.py
@@ -23,7 +23,6 @@
from rest_framework.response import Response
from rest_framework.request import Request
-from lms.djangoapps.discussion.views import is_privileged_user
from xmodule.course_module import CourseBlock
from xmodule.modulestore.django import modulestore
from xmodule.tabs import CourseTabList
@@ -34,6 +33,7 @@
from lms.djangoapps.discussion.toggles import ENABLE_LEARNERS_TAB_IN_DISCUSSIONS_MFE, \
ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE
from lms.djangoapps.discussion.toggles_utils import reported_content_email_notification_enabled
+from lms.djangoapps.discussion.views import is_user_moderator
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider
from openedx.core.djangoapps.discussions.utils import get_accessible_discussion_xblocks
from openedx.core.djangoapps.django_comment_common import comment_client
@@ -1190,8 +1190,7 @@ def _handle_abuse_flagged_field(form_value, user, cc_content):
else:
comment_flagged.send(sender='flag_abuse_for_comment', user=user, post=cc_content)
else:
- remove_all = bool(user.id != cc_content["user_id"] and is_privileged_user(course_key,
- User.objects.get(id=user.id)))
+ remove_all = bool(is_user_moderator(course_key, User.objects.get(id=user.id)))
cc_content.unFlagAbuse(user, cc_content, remove_all)
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py
index 22356283d917..dd8182883891 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_api.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py
@@ -2826,7 +2826,7 @@ def test_abuse_flagged(self, old_flagged, new_flagged):
@ddt.data(
(False, True),
- (True, False),
+ (True, True),
)
@ddt.unpack
def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all):
@@ -2837,10 +2837,6 @@ def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all):
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
thread as unreported.
- If moderator is author of a thread, we want to restrict
- the usage of the remove_all flag, so it cant be used
- to remove all abuse_flaggers from a moderator post
- by the moderator itself.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR)
self.register_get_user_response(self.user)
@@ -3311,7 +3307,7 @@ def test_abuse_flagged(self, old_flagged, new_flagged):
@ddt.data(
(False, True),
- (True, False),
+ (True, True),
)
@ddt.unpack
def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all):
@@ -3322,10 +3318,6 @@ def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all):
pass the "all" flag to the api. This will indicate
to the api to clear all abuse_flaggers, and mark the
comment as unreported.
- If moderator is author of a comment, we want to restrict
- the usage of the remove_all flag, so it cant be used
- to remove all abuse_flaggers from a moderator post
- by the moderator itself.
"""
_assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR)
self.register_get_user_response(self.user)
diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py
index e77561bd1d9a..74eb690dd9f7 100644
--- a/lms/djangoapps/discussion/tasks.py
+++ b/lms/djangoapps/discussion/tasks.py
@@ -11,7 +11,6 @@
from django.conf import settings # lint-amnesty, pylint: disable=unused-import
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.contrib.sites.models import Site
-from django.urls import reverse
from edx_ace import ace
from edx_ace.recipient import Recipient
from edx_ace.utils import date
@@ -19,6 +18,9 @@
from eventtracking import tracker
from opaque_keys.edx.keys import CourseKey
from six.moves.urllib.parse import urljoin
+
+from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, ENABLE_VIEW_MFE_IN_IFRAME
+from openedx.core.djangoapps.discussions.url_helpers import get_discussions_mfe_url
from xmodule.modulestore.django import modulestore
import openedx.core.djangoapps.django_comment_common.comment_client as cc
@@ -206,10 +208,15 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu
message_context.update(context)
thread_author = User.objects.get(id=context['thread_author_id'])
comment_author = User.objects.get(id=context['comment_author_id'])
+ show_mfe_post_link = ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(
+ context['course_id']
+ ) and not ENABLE_VIEW_MFE_IN_IFRAME.is_enabled()
+ post_link = _get_mfe_thread_url(context) if show_mfe_post_link else _get_thread_url(context)
+
message_context.update({
'thread_username': thread_author.username,
'comment_username': comment_author.username,
- 'post_link': _get_thread_url(context),
+ 'post_link': post_link,
'comment_created_at': date.deserialize(context['comment_created_at']),
'thread_created_at': date.deserialize(context['thread_created_at'])
})
@@ -231,12 +238,9 @@ def _get_mfe_thread_url(context):
"""
Get thread url for new MFE
"""
- scheme = 'https' if settings.HTTPS == 'on' else 'http'
- forum_url = reverse('forum_form_discussion', args=[context['course_id']])
- base_url = f"{scheme}://{context['site'].domain}{forum_url}"
- mfe_post_link = f"?discussions_experience=new#posts/{context['thread_id']}"
- post_link = urljoin(base_url, mfe_post_link)
- return post_link
+ forum_url = get_discussions_mfe_url(course_key=context['course_id'])
+ mfe_post_link = f"posts/{context['thread_id']}"
+ return urljoin(forum_url, mfe_post_link)
def _get_thread_url(context): # lint-amnesty, pylint: disable=missing-function-docstring
diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py
index 9e73967dde23..332ac6e40fa0 100644
--- a/lms/djangoapps/discussion/tests/test_views.py
+++ b/lms/djangoapps/discussion/tests/test_views.py
@@ -9,6 +9,7 @@
import ddt
import pytest
+from django.conf import settings
from django.http import Http404
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
@@ -54,7 +55,8 @@
from lms.djangoapps.discussion.toggles import (
ENABLE_DISCUSSIONS_MFE,
ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE,
- ENABLE_DISCUSSIONS_MFE_BANNER
+ ENABLE_DISCUSSIONS_MFE_BANNER,
+ ENABLE_VIEW_MFE_IN_IFRAME
)
from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler
from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory
@@ -2272,6 +2274,9 @@ def test_thread_viewed_event(self, mock_perform_request):
'openedx.core.djangoapps.django_comment_common.comment_client.utils.perform_request',
Mock(
return_value={
+ "id": "test_thread",
+ "title": "Title",
+ "body": "",
"default_sort_key": "date",
"upvoted_ids": [],
"downvoted_ids": [],
@@ -2331,78 +2336,112 @@ def test_correct_experience_is_shown(self, toggle_enabled, experience):
assert "discussions-mfe-tab-embed" not in content
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
- @ddt.data(*itertools.product(("learner", "staff"), (True, False)))
+ @ddt.data(*itertools.product(("learner", "staff"), (True, False), (True, False)))
@ddt.unpack
- def test_redirect_from_legacy_base_url_to_new_experience(self, user_role, toggle_enabled):
+ def test_redirect_from_legacy_base_url_to_new_experience(self, user_role, toggle_enabled, in_iframe):
"""
Verify that the requested is redirected to MFE homepage when
ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE flag is enabled. Privileged users will
be able to view legacy experience. For learners, if ENABLE_DISCUSSIONS_MFE_BANNER
flag is enabled, they will be able to use legacy otherwise they will be redirected
to MFE.
+ IF ENABLE_VIEW_IN_IFRAME is disabled then it will redirect to discussions domain
"""
if user_role == "staff":
user = self.staff_user
elif user_role == "learner":
user = self.user
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, toggle_enabled):
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, toggle_enabled), \
+ override_waffle_flag(ENABLE_VIEW_MFE_IN_IFRAME, in_iframe):
self.client.login(username=user.username, password='test')
url = reverse("forum_form_discussion", args=[self.course.id])
response = self.client.get(url)
content = response.content.decode('utf8')
- if toggle_enabled:
- assert "discussions-mfe-tab-embed" in content
+ if in_iframe:
+ if toggle_enabled:
+ assert "discussions-mfe-tab-embed" in content
+ else:
+ assert "discussions-mfe-tab-embed" not in content
else:
- assert "discussions-mfe-tab-embed" not in content
+ if toggle_enabled:
+ assert response.status_code == 302
+ expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}"
+ assert response.url == expected_url
+ else:
+ assert response.status_code == 200
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
- @ddt.data(*itertools.product(("learner", "staff"), (True, False)))
+ @ddt.data(*itertools.product(("learner", "staff"), (True, False), (True, False)))
@ddt.unpack
- def test_redirect_from_legacy_profile_url_to_new_experience(self, user_role, toggle_enabled):
+ def test_redirect_from_legacy_profile_url_to_new_experience(self, user_role, toggle_enabled, in_iframe):
"""
Verify that the requested is redirected to MFE homepage when
ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE flag is enabled. This redirect is only
for learners and not for privileged users.
+ IF ENABLE_VIEW_IN_IFRAME is disabled then it will redirect to discussions domain
"""
if user_role == "staff":
user = self.staff_user
elif user_role == "learner":
user = self.user
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, toggle_enabled):
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, toggle_enabled), \
+ override_waffle_flag(ENABLE_VIEW_MFE_IN_IFRAME, in_iframe):
self.client.login(username=user.username, password='test')
url = reverse("user_profile", args=[self.course.id, user.id])
response = self.client.get(url)
content = response.content.decode('utf8')
- if toggle_enabled and user == "learner":
- assert "discussions-mfe-tab-embed" in content
+
+ if in_iframe:
+ if toggle_enabled and user == "learner":
+ assert "discussions-mfe-tab-embed" in content
+ else:
+ assert "discussions-mfe-tab-embed" not in content
else:
- assert "discussions-mfe-tab-embed" not in content
+ if toggle_enabled:
+ if user_role == "staff":
+ assert "discussions-mfe-tab-embed" not in content
+ else:
+ assert response.status_code == 302
+ expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/learners"
+ assert response.url == expected_url
+ else:
+ assert "discussions-mfe-tab-embed" not in content
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
- @ddt.data(*itertools.product(("learner", "staff"), (True, False)))
+ @ddt.data(*itertools.product(("learner", "staff"), (True, False), (True, False)))
@ddt.unpack
- def test_correct_experience_for_single_thread_url_for_everyone_flag(self, user_role, toggle_enabled):
+ def test_correct_experience_for_single_thread_url_for_everyone_flag(self, user_role, toggle_enabled, in_iframe):
"""
Verify that the correct experience is shown based on the MFE toggle for everyone
for Legacy single thread url
+ IF ENABLE_VIEW_IN_IFRAME is disabled then it will redirect to discussions domain
"""
if user_role == "staff":
user = self.staff_user
elif user_role == "learner":
user = self.user
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, toggle_enabled):
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, toggle_enabled), \
+ override_waffle_flag(ENABLE_VIEW_MFE_IN_IFRAME, in_iframe):
self.client.login(username=user.username, password='test')
url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"])
response = self.client.get(url)
content = response.content.decode('utf8')
- if toggle_enabled and user == "learner":
- assert "discussions-mfe-tab-embed" in content
+ if in_iframe:
+ if toggle_enabled and user == "learner":
+ assert "discussions-mfe-tab-embed" in content
+ else:
+ assert "discussions-mfe-tab-embed" not in content
else:
- assert "discussions-mfe-tab-embed" not in content
+ if toggle_enabled:
+ assert response.status_code == 302
+ expected_url = f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/test_thread"
+ assert response.url == expected_url
+ else:
+ assert "discussions-mfe-tab-embed" not in content
@override_settings(DISCUSSIONS_MICROFRONTEND_URL="http://test.url")
@ddt.data(*itertools.product(("legacy", "new"), (True, False)))
@@ -2412,7 +2451,8 @@ def test_correct_experience_for_learner_banner_flag(self, experience, toggle_ena
Verify that the correct experience is shown based on the MFE toggle for everyone
for Legacy single thread url
"""
- with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, True):
+ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE, True), \
+ override_waffle_flag(ENABLE_VIEW_MFE_IN_IFRAME, True):
with override_waffle_flag(ENABLE_DISCUSSIONS_MFE_BANNER, toggle_enabled):
self.client.login(username=self.user.username, password='test')
url = reverse("forum_form_discussion", args=[self.course.id])
diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py
index de46629458cb..455098fd07c3 100644
--- a/lms/djangoapps/discussion/views.py
+++ b/lms/djangoapps/discussion/views.py
@@ -269,6 +269,29 @@ def inline_discussion(request, course_key, discussion_id):
})
+def redirect_forum_url_to_new_mfe(request, course_id):
+ """
+ Returns the redirect link when user opens default discussion homepage
+ """
+ course_key = CourseKey.from_string(course_id)
+ discussions_mfe_enabled_for_everyone = ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(course_key)
+ view_mfe_in_iframe = ENABLE_VIEW_MFE_IN_IFRAME.is_enabled(course_key)
+ privileged_user = is_privileged_user(course_key, request.user)
+
+ redirect_url = None
+ if discussions_mfe_enabled_for_everyone and (not view_mfe_in_iframe):
+ mfe_base_url = settings.DISCUSSIONS_MICROFRONTEND_URL
+ redirect_url = f"{mfe_base_url}/{str(course_key)}"
+ elif discussions_mfe_enabled_for_everyone and (not privileged_user):
+ discussion_experience = request.GET.get('discussions_experience', None)
+ banner_enabled = ENABLE_DISCUSSIONS_MFE_BANNER.is_enabled(course_key)
+ redirect_to_mfe = (discussion_experience is None) or (not banner_enabled)
+ if redirect_to_mfe and discussion_experience == "legacy":
+ mfe_context = _discussions_mfe_context(request.GET, course_key, True, False, False)
+ redirect_url = mfe_context['mfe_url']
+ return redirect_url
+
+
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@login_required
@use_bulk_ops
@@ -307,34 +330,33 @@ def forum_form_discussion(request, course_key):
'corrected_text': query_params['corrected_text'],
})
else:
- discussions_mfe_enabled_for_everyone = ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(course_key)
- privileged_user = is_privileged_user(course_key, request.user)
- if discussions_mfe_enabled_for_everyone and (not privileged_user):
- discussion_experience = request.GET.get('discussions_experience', None)
- banner_enabled = ENABLE_DISCUSSIONS_MFE_BANNER.is_enabled(course_key)
- redirect_to_mfe = (discussion_experience is None) or (not banner_enabled)
- if redirect_to_mfe and discussion_experience == "legacy":
- mfe_context = _discussions_mfe_context(request.GET, course_key, True, False, False)
- return redirect(mfe_context['mfe_url'])
+ redirect_url = redirect_forum_url_to_new_mfe(request, str(course.id))
+ if redirect_url:
+ return redirect(redirect_url)
course_id = str(course.id)
tab_view = CourseTabView()
return tab_view.get(request, course_id, 'discussion')
-def redirect_url_to_new_mfe(request, course_id, thread_id):
+def redirect_thread_url_to_new_mfe(request, course_id, thread_id):
"""
Returns MFE url of the thread if the user is not privileged
"""
course_key = CourseKey.from_string(course_id)
discussions_mfe_enabled_for_everyone = ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(course_key)
- if discussions_mfe_enabled_for_everyone and (not is_privileged_user(course_key, request.user)):
+ view_mfe_in_iframe = ENABLE_VIEW_MFE_IN_IFRAME.is_enabled(course_key)
+ redirect_url = None
+ if discussions_mfe_enabled_for_everyone and (not view_mfe_in_iframe):
+ mfe_base_url = settings.DISCUSSIONS_MICROFRONTEND_URL
+ if thread_id:
+ redirect_url = f"{mfe_base_url}/{str(course_key)}/posts/{thread_id}"
+ elif discussions_mfe_enabled_for_everyone and (not is_privileged_user(course_key, request.user)):
discussion_experience = request.GET.get('discussions_experience', None)
if (discussion_experience is None) and (thread_id is not None):
mfe_context = _discussions_mfe_context(request.GET, course_key, True, False, False)
redirect_url = f"{mfe_context['mfe_url']}#posts/{thread_id}"
- return redirect_url
- return None
+ return redirect_url
@require_GET
@@ -386,7 +408,7 @@ def single_thread(request, course_key, discussion_id, thread_id):
'annotated_content_info': annotated_content_info,
})
else:
- redirect_url = redirect_url_to_new_mfe(request, str(course.id), thread_id)
+ redirect_url = redirect_thread_url_to_new_mfe(request, str(course.id), thread_id)
if redirect_url:
return redirect(redirect_url)
@@ -650,8 +672,12 @@ def user_profile(request, course_key, user_id):
})
else:
discussions_mfe_enabled_for_everyone = ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(course_key)
+ view_mfe_in_iframe = ENABLE_VIEW_MFE_IN_IFRAME.is_enabled(course_key)
privileged_user = is_privileged_user(course_key, request.user)
- if discussions_mfe_enabled_for_everyone and (not privileged_user):
+ if discussions_mfe_enabled_for_everyone and (not view_mfe_in_iframe):
+ mfe_base_url = settings.DISCUSSIONS_MICROFRONTEND_URL
+ return redirect(f"{mfe_base_url}/{str(course_key)}/learners")
+ elif discussions_mfe_enabled_for_everyone and (not privileged_user):
mfe_context = _discussions_mfe_context(request.GET, course_key, True, False, False)
return redirect(mfe_context['mfe_url'])
@@ -803,7 +829,8 @@ def is_course_staff(course_key: CourseKey, user: User):
def is_privileged_user(course_key: CourseKey, user: User):
"""
Returns True if user has one of following course role
- Administrator, Moderator, Group Moderator, Community TA
+ Administrator, Moderator, Group Moderator, Community TA,
+ Global Staff, Course Instructor or Course Staff.
"""
forum_roles = [
FORUM_ROLE_COMMUNITY_TA,
@@ -815,6 +842,21 @@ def is_privileged_user(course_key: CourseKey, user: User):
return GlobalStaff().has_user(user) or is_course_staff(course_key, user) or has_course_role
+def is_user_moderator(course_key: CourseKey, user: User):
+ """
+ Returns True if user has one of following course role
+ Administrator, Moderator, Group Moderator, Community TA.
+ """
+ forum_roles = [
+ FORUM_ROLE_COMMUNITY_TA,
+ FORUM_ROLE_GROUP_MODERATOR,
+ FORUM_ROLE_MODERATOR,
+ FORUM_ROLE_ADMINISTRATOR
+ ]
+ has_course_role = Role.user_has_role_for_course(user, course_key, forum_roles)
+ return has_course_role
+
+
class DiscussionBoardFragmentView(EdxFragmentView):
"""
Component implementation of the discussion board.
diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py
index 562686acbe1e..a1f206f9e05a 100644
--- a/lms/djangoapps/learner_dashboard/api/v0/views.py
+++ b/lms/djangoapps/learner_dashboard/api/v0/views.py
@@ -369,8 +369,9 @@ def get(self, request):
return Response(status=400)
recommended_courses = []
+ fields = ['title', 'owners', 'marketing_url']
for course_id in course_keys:
- course_data = get_course_data(course_id)
+ course_data = get_course_data(course_id, fields)
if course_data:
recommended_courses.append({
'course_key': course_id,
diff --git a/lms/djangoapps/learner_home/mock_data.json b/lms/djangoapps/learner_home/mock_data.json
index d252d16d9b3a..a505c67aa386 100644
--- a/lms/djangoapps/learner_home/mock_data.json
+++ b/lms/djangoapps/learner_home/mock_data.json
@@ -16,8 +16,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -103,8 +107,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -158,8 +166,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -205,8 +217,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -278,8 +294,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -340,8 +360,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -387,8 +411,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -464,8 +492,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": true,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": true,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -530,8 +562,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": true,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -582,8 +618,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": true,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -664,8 +704,12 @@
"enrollment": {
"accessExpirationDate": "11/11/2000",
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": true,
+ "isStaff": false
+ },
"isAudit": true,
"isAuditAccessExpired": true,
"isEmailEnabled": false,
@@ -716,8 +760,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": true,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -777,8 +825,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -850,8 +902,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -912,8 +968,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -945,8 +1005,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": true,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1018,8 +1082,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": true,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1081,8 +1149,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": true,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1128,8 +1200,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": true,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1202,8 +1278,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": true,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1265,8 +1345,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": true,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1348,8 +1432,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1456,8 +1544,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1553,8 +1645,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1635,8 +1731,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": true,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1717,8 +1817,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1788,8 +1892,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1845,8 +1953,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1927,8 +2039,12 @@
"enrollment": {
"accessExpirationDate": "11/11/3030",
"canUpgrade": null,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": null,
"isEmailEnabled": false,
@@ -1986,8 +2102,12 @@
"enrollment": {
"accessExpirationDate": null,
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -2074,8 +2194,12 @@
"enrollment": {
"accessExpirationDate": null,
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -2151,8 +2275,12 @@
"enrollment": {
"accessExpirationDate": null,
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -2213,8 +2341,12 @@
"enrollment": {
"accessExpirationDate": null,
"canUpgrade": false,
- "hasFinished": false,
"hasStarted": false,
+ "hasAccess": {
+ "hasUnmetPrerequisites": false,
+ "isTooEarly": false,
+ "isStaff": false
+ },
"isAudit": false,
"isAuditAccessExpired": false,
"isEmailEnabled": false,
@@ -2275,21 +2407,9 @@
"isNeeded": true,
"sendEmailUrl": "sendConfirmation@edx.org"
},
- "enterpriseDashboards": {
- "availableDashboards": [
- {
- "label": "edX, Inc.",
- "url": "/edx-dashboard"
- },
- {
- "label": "Harvard",
- "url": "/harvard-dashboard"
- }
- ],
- "mostRecentDashboard": {
- "label": "edX, Inc.",
- "url": "/edx-dashboard"
- }
+ "enterpriseDashboard": {
+ "label": "edX, Inc.",
+ "url": "/edx-dashboard"
},
"platformSettings": {
"supportEmail": "support@example.com",
diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py
index 48d041ee359e..b054e494c9c7 100644
--- a/lms/djangoapps/learner_home/serializers.py
+++ b/lms/djangoapps/learner_home/serializers.py
@@ -1,12 +1,15 @@
"""
Serializers for the Learner Dashboard
"""
+from urllib.parse import urljoin
+from django.conf import settings
from django.urls import reverse
from rest_framework import serializers
from common.djangoapps.course_modes.models import CourseMode
from openedx.features.course_experience import course_home_url
+from xmodule.data import CertificatesDisplayBehaviors
class PlatformSettingsSerializer(serializers.Serializer):
@@ -92,6 +95,41 @@ def get_resumeUrl(self, instance):
return self.context.get("resume_course_urls", {}).get(instance.course_id)
+class HasAccessSerializer(serializers.Serializer):
+ """
+ Info determining whether a user should be able to view course material.
+ Mirrors logic in "show_courseware_links_for" from old dashboard.py
+ """
+
+ hasUnmetPrerequisites = serializers.SerializerMethodField()
+ isTooEarly = serializers.SerializerMethodField()
+ isStaff = serializers.SerializerMethodField()
+
+ def _get_course_access_checks(self, enrollment):
+ """Internal helper to unpack access object for this particular enrollment"""
+ return self.context.get("course_access_checks", {}).get(
+ enrollment.course_id, {}
+ )
+
+ def get_hasUnmetPrerequisites(self, enrollment):
+ """Whether or not a course has unmet prerequisites"""
+ return self._get_course_access_checks(enrollment).get(
+ "has_unmet_prerequisites", False
+ )
+
+ def get_isTooEarly(self, enrollment):
+ """Determine if the course is open to a learner (course has started or user has early beta access)"""
+ return self._get_course_access_checks(enrollment).get(
+ "is_too_early_to_view", False
+ )
+
+ def get_isStaff(self, enrollment):
+ """Determine whether a user has staff access to this course"""
+ return self._get_course_access_checks(enrollment).get(
+ "user_has_staff_access", False
+ )
+
+
class EnrollmentSerializer(serializers.Serializer):
"""
Info about this particular enrollment.
@@ -110,7 +148,7 @@ class EnrollmentSerializer(serializers.Serializer):
accessExpirationDate = serializers.SerializerMethodField()
isAudit = serializers.SerializerMethodField()
hasStarted = serializers.SerializerMethodField()
- hasFinished = serializers.SerializerMethodField()
+ hasAccess = HasAccessSerializer(source="*")
isVerified = serializers.SerializerMethodField()
canUpgrade = serializers.SerializerMethodField()
isAuditAccessExpired = serializers.SerializerMethodField()
@@ -136,10 +174,6 @@ def get_hasStarted(self, enrollment):
)
return resume_button_url is not None
- def get_hasFinished(self, enrollment):
- # TODO - AU-796
- return False
-
def get_isVerified(self, enrollment):
return enrollment.is_verified_enrollment()
@@ -179,11 +213,59 @@ class GradeDataSerializer(serializers.Serializer):
class CertificateSerializer(serializers.Serializer):
"""Certificate availability info"""
- availableDate = serializers.DateTimeField(allow_null=True)
- isRestricted = serializers.BooleanField()
- isEarned = serializers.BooleanField()
- isDownloadable = serializers.BooleanField()
- certPreviewUrl = serializers.URLField(allow_null=True)
+ availableDate = serializers.SerializerMethodField()
+ isRestricted = serializers.SerializerMethodField()
+ isEarned = serializers.SerializerMethodField()
+ isDownloadable = serializers.SerializerMethodField()
+ certPreviewUrl = serializers.SerializerMethodField()
+
+ def get_cert_info(self, enrollment):
+ """Utility to grab certificate info for this enrollment or empty object"""
+ return self.context.get("cert_statuses", {}).get(enrollment.course.id, {})
+
+ def get_availableDate(self, enrollment):
+ """Available date changes based off of Certificate display behavior"""
+ course_overview = enrollment.course_overview
+ available_date = course_overview.certificate_available_date
+
+ if settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False):
+ if (
+ course_overview.certificates_display_behavior
+ == CertificatesDisplayBehaviors.END_WITH_DATE
+ and course_overview.certificate_available_date
+ ):
+ available_date = course_overview.certificate_available_date
+ elif (
+ course_overview.certificates_display_behavior
+ == CertificatesDisplayBehaviors.END
+ and course_overview.end
+ ):
+ available_date = course_overview.end
+ else:
+ available_date = course_overview.certificate_available_date
+
+ return serializers.DateTimeField().to_representation(available_date)
+
+ def get_isRestricted(self, enrollment):
+ """Cert is considered restricted based on certificate status"""
+ return self.get_cert_info(enrollment).get("status") == "restricted"
+
+ def get_isEarned(self, enrollment):
+ """Cert is considered earned based on certificate status"""
+ is_earned_states = ("downloadable", "certificate_earned_but_not_available")
+ return self.get_cert_info(enrollment).get("status") in is_earned_states
+
+ def get_isDownloadable(self, enrollment):
+ """Cert is considered downloadable based on certificate status"""
+ return self.get_cert_info(enrollment).get("status") == "downloadable"
+
+ def get_certPreviewUrl(self, enrollment):
+ """Cert preview URL comes from certificate info"""
+ cert_info = self.get_cert_info(enrollment)
+ if not cert_info.get("show_cert_web_view", False):
+ return None
+ else:
+ return cert_info.get("cert_web_view_url")
class AvailableEntitlementSessionSerializer(serializers.Serializer):
@@ -238,11 +320,11 @@ class LearnerEnrollmentSerializer(serializers.Serializer):
course = CourseSerializer()
courseRun = CourseRunSerializer(source="*")
enrollment = EnrollmentSerializer(source="*")
+ certificate = CertificateSerializer(source="*")
# TODO - remove "allow_null" as each of these are implemented, temp for testing.
courseProvider = CourseProviderSerializer(allow_null=True)
gradeData = GradeDataSerializer(allow_null=True)
- certificate = CertificateSerializer(allow_null=True)
entitlements = EntitlementSerializer(allow_null=True)
programs = ProgramsSerializer(allow_null=True)
@@ -275,17 +357,11 @@ class EmailConfirmationSerializer(serializers.Serializer):
class EnterpriseDashboardSerializer(serializers.Serializer):
"""Serializer for individual enterprise dashboard data"""
- label = serializers.CharField()
- url = serializers.URLField()
-
+ label = serializers.CharField(source='name')
+ url = serializers.SerializerMethodField()
-class EnterpriseDashboardsSerializer(serializers.Serializer):
- """Listing of available enterprise dashboards"""
-
- availableDashboards = serializers.ListField(
- child=EnterpriseDashboardSerializer(), allow_empty=True
- )
- mostRecentDashboard = EnterpriseDashboardSerializer()
+ def get_url(self, instance):
+ return urljoin(settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL, instance['uuid'])
class LearnerDashboardSerializer(serializers.Serializer):
@@ -294,7 +370,7 @@ class LearnerDashboardSerializer(serializers.Serializer):
requires_context = True
emailConfirmation = EmailConfirmationSerializer()
- enterpriseDashboards = EnterpriseDashboardsSerializer()
+ enterpriseDashboard = EnterpriseDashboardSerializer(allow_null=True)
platformSettings = PlatformSettingsSerializer()
courses = serializers.SerializerMethodField()
suggestedCourses = serializers.ListField(
diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py
index d759fe4b296d..f9c41172aa7f 100644
--- a/lms/djangoapps/learner_home/test_serializers.py
+++ b/lms/djangoapps/learner_home/test_serializers.py
@@ -5,6 +5,8 @@
from unittest import mock
from uuid import uuid4
+from django.conf import settings
+
import ddt
from common.djangoapps.course_modes.models import CourseMode
@@ -20,9 +22,10 @@
CourseSerializer,
EmailConfirmationSerializer,
EnrollmentSerializer,
- EnterpriseDashboardsSerializer,
+ EnterpriseDashboardSerializer,
EntitlementSerializer,
GradeDataSerializer,
+ HasAccessSerializer,
LearnerEnrollmentSerializer,
PlatformSettingsSerializer,
ProgramsSerializer,
@@ -36,6 +39,7 @@
random_date,
random_url,
)
+from xmodule.data import CertificatesDisplayBehaviors
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -48,21 +52,20 @@ def setUpClass(cls):
super().setUpClass()
cls.user = UserFactory()
- def create_test_enrollment(self):
+ def create_test_enrollment(self, course_mode=CourseMode.AUDIT):
"""Create a test user, course, and enrollment. Return the enrollment."""
course = CourseFactory(self_paced=True)
CourseModeFactory(
course_id=course.id,
- mode_slug=CourseMode.AUDIT,
+ mode_slug=course_mode,
)
- test_enrollment = CourseEnrollmentFactory(
- course_id=course.id, mode=CourseMode.AUDIT
- )
+ test_enrollment = CourseEnrollmentFactory(course_id=course.id, mode=course_mode)
# Add extra info to exercise serialization
test_enrollment.course_overview.marketing_url = random_url()
test_enrollment.course_overview.end = random_date()
+ test_enrollment.course_overview.certificate_available_date = random_date()
return test_enrollment
@@ -151,6 +154,94 @@ def test_with_data(self):
assert output[key] is not None
+@ddt.ddt
+class TestHasAccessSerializer(LearnerDashboardBaseTest):
+ """Tests for the HasAccessSerializer"""
+
+ def create_test_context(self, course):
+ return {
+ "course_access_checks": {
+ course.id: {
+ "has_unmet_prerequisites": False,
+ "is_too_early_to_view": False,
+ "user_has_staff_access": False,
+ }
+ }
+ }
+
+ @ddt.data(True, False)
+ def test_unmet_prerequisites(self, has_unmet_prerequisites):
+ # Given an enrollment
+ input_data = self.create_test_enrollment()
+ input_context = self.create_test_context(input_data.course)
+
+ # ... without unmet prerequisites
+ if has_unmet_prerequisites:
+ # ... or with unmet prerequisites
+ prerequisite_course = CourseFactory()
+ input_context.update(
+ {
+ "course_access_checks": {
+ input_data.course.id: {
+ "has_unmet_prerequisites": has_unmet_prerequisites,
+ }
+ }
+ }
+ )
+
+ # When I serialize
+ output_data = HasAccessSerializer(input_data, context=input_context).data
+
+ # Then "hasUnmetPrerequisites" is outputs correctly
+ self.assertEqual(output_data["hasUnmetPrerequisites"], has_unmet_prerequisites)
+
+ @ddt.data(True, False)
+ def test_is_staff(self, is_staff):
+ # Given an enrollment
+ input_data = self.create_test_enrollment()
+ input_context = self.create_test_context(input_data.course)
+
+ # Where user has/hasn't staff access
+ input_context.update(
+ {
+ "course_access_checks": {
+ input_data.course.id: {
+ "user_has_staff_access": is_staff,
+ }
+ }
+ }
+ )
+
+ # When I serialize
+ output_data = HasAccessSerializer(input_data, context=input_context).data
+
+ # Then "isStaff" serializes properly
+ self.assertEqual(output_data["isStaff"], is_staff)
+
+ @ddt.data(True, False)
+ def test_is_too_early(self, is_too_early):
+ # Given an enrollment
+ input_data = self.create_test_enrollment()
+ input_context = self.create_test_context(input_data.course)
+
+ # Where the course is/n't yet open for a learner
+ input_context.update(
+ {
+ "course_access_checks": {
+ input_data.course.id: {
+ "is_too_early_to_view": is_too_early,
+ }
+ }
+ }
+ )
+
+ # When I serialize
+ output_data = HasAccessSerializer(input_data, context=input_context).data
+
+ # Then "isTooEarly" serializes properly
+ self.assertEqual(output_data["isTooEarly"], is_too_early)
+
+
@ddt.ddt
class TestEnrollmentSerializer(LearnerDashboardBaseTest):
"""Tests for the EnrollmentSerializer"""
@@ -259,7 +350,8 @@ def test_happy_path(self):
}
-class TestCertificateSerializer(TestCase):
+@ddt.ddt
+class TestCertificateSerializer(LearnerDashboardBaseTest):
"""Tests for the CertificateSerializer"""
@classmethod
@@ -276,18 +368,201 @@ def generate_test_certificate_info(cls):
"honorCertDownloadUrl": random_url(allow_null=True),
}
- def test_happy_path(self):
- input_data = self.generate_test_certificate_info()
- output_data = CertificateSerializer(input_data).data
-
- assert output_data == {
- "availableDate": datetime_to_django_format(input_data["availableDate"]),
- "isRestricted": input_data["isRestricted"],
- "isEarned": input_data["isEarned"],
- "isDownloadable": input_data["isDownloadable"],
- "certPreviewUrl": input_data["certPreviewUrl"],
+ def create_test_context(self, course):
+ """Get a test context object with an available certificate"""
+ return {
+ "cert_statuses": {
+ course.id: {
+ "cert_web_view_url": random_url(),
+ "status": "downloadable",
+ "show_cert_web_view": True,
+ }
+ }
}
+ def test_with_data(self):
+ """Simple mappings test for a course with an available certificate"""
+ # Given a verified enrollment
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+
+ # ... with a certificate
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and some data preemptively gathered
+ available_date = random_date()
+ input_data.course.certificate_available_date = available_date
+ cert_url = input_context["cert_statuses"][input_data.course.id][
+ "cert_web_view_url"
+ ]
+
+ # When I get certificate info
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then all the info is provided correctly
+ self.assertDictEqual(
+ output_data,
+ {
+ "availableDate": datetime_to_django_format(available_date),
+ "isRestricted": False,
+ "isEarned": True,
+ "isDownloadable": True,
+ "certPreviewUrl": cert_url,
+ },
+ )
+
+ @mock.patch.dict(settings.FEATURES, ENABLE_V2_CERT_DISPLAY_SETTINGS=False)
+ def test_available_date_old_format(self):
+ # Given new cert display settings are not enabled
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_data.course.certificate_available_date = random_date()
+ input_context = self.create_test_context(input_data.course)
+
+ # When I get certificate info
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then the available date is defaulted to the certificate available date
+ expected_available_date = datetime_to_django_format(
+ input_data.course.certificate_available_date
+ )
+ self.assertEqual(output_data["availableDate"], expected_available_date)
+
+ @mock.patch.dict(settings.FEATURES, ENABLE_V2_CERT_DISPLAY_SETTINGS=True)
+ def test_available_date_course_end(self):
+ # Given new cert display settings are enabled
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and certificate display behavior is set to the course end date
+ input_data.course.certificates_display_behavior = (
+ CertificatesDisplayBehaviors.END
+ )
+
+ # When I try to get cert available date
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then the available date is the course end date
+ expected_available_date = datetime_to_django_format(input_data.course.end)
+ self.assertEqual(output_data["availableDate"], expected_available_date)
+
+ @mock.patch.dict(settings.FEATURES, ENABLE_V2_CERT_DISPLAY_SETTINGS=True)
+ def test_available_date_specific_end(self):
+ # Given new cert display settings are enabled
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and certificate display behavior is set to a specified date
+ input_data.course.certificate_available_date = random_date()
+ input_data.course.certificates_display_behavior = (
+ CertificatesDisplayBehaviors.END_WITH_DATE
+ )
+
+ # When I try to get cert available date
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then the available date is the course end date
+ expected_available_date = datetime_to_django_format(
+ input_data.course.certificate_available_date
+ )
+ self.assertEqual(output_data["availableDate"], expected_available_date)
+
+ @ddt.data(
+ ("downloadable", False),
+ ("notpassing", False),
+ ("restricted", True),
+ ("auditing", False),
+ )
+ @ddt.unpack
+ def test_is_restricted(self, cert_status, is_restricted_expected):
+ """Test for isRestricted field"""
+ # Given a verified enrollment with a certificate
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and a cert status {cert_status}
+ input_context["cert_statuses"][input_data.course.id]["status"] = cert_status
+
+ # When I get certificate info
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then isRestricted should be calculated correctly
+ self.assertEqual(output_data["isRestricted"], is_restricted_expected)
+
+ @ddt.data(
+ ("downloadable", True),
+ ("notpassing", False),
+ ("restricted", False),
+ ("auditing", False),
+ ("certificate_earned_but_not_available", True),
+ )
+ @ddt.unpack
+ def test_is_earned(self, cert_status, is_earned_expected):
+ """Test for isEarned field"""
+ # Given a verified enrollment with a certificate
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and a cert status {cert_status}
+ input_context["cert_statuses"][input_data.course.id]["status"] = cert_status
+
+ # When I get certificate info
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then isEarned should be calculated correctly
+ self.assertEqual(output_data["isEarned"], is_earned_expected)
+
+ @ddt.data(
+ ("downloadable", True),
+ ("notpassing", False),
+ ("restricted", False),
+ ("auditing", False),
+ ("certificate_earned_but_not_available", False),
+ )
+ @ddt.unpack
+ def test_is_downloadable(self, cert_status, is_downloadable_expected):
+ """Test for isDownloadable field"""
+ # Given a verified enrollment with a certificate
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and a cert status {cert_status}
+ input_context["cert_statuses"][input_data.course.id]["status"] = cert_status
+
+ # When I get certificate info
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then isDownloadable should be calculated correctly
+ self.assertEqual(output_data["isDownloadable"], is_downloadable_expected)
+
+ @ddt.data(
+ (True, random_url()),
+ (False, random_url()),
+ (True, None),
+ (False, None),
+ )
+ @ddt.unpack
+ def test_cert_preview_url(self, show_cert_web_view, cert_web_view_url):
+ """Test for certPreviewUrl field"""
+ # Given a verified enrollment with a certificate
+ input_data = self.create_test_enrollment(course_mode=CourseMode.VERIFIED)
+ input_context = self.create_test_context(input_data.course)
+
+ # ... and settings show_cert_web_view and cert_web_view_url
+ input_context["cert_statuses"][input_data.course.id][
+ "show_cert_web_view"
+ ] = show_cert_web_view
+ input_context["cert_statuses"][input_data.course.id][
+ "cert_web_view_url"
+ ] = cert_web_view_url
+
+ # When I get certificate info
+ output_data = CertificateSerializer(input_data, context=input_context).data
+
+ # Then certPreviewUrl should be calculated correctly
+ self.assertEqual(
+ output_data["certPreviewUrl"],
+ cert_web_view_url if show_cert_web_view else None,
+ )
+
class TestEntitlementSerializer(TestCase):
"""Tests for the EntitlementSerializer"""
@@ -559,36 +834,25 @@ def test_happy_path(self):
)
-class TestEnterpriseDashboardsSerializer(TestCase):
- """High-level tests for EnterpriseDashboardsSerializer"""
-
- @classmethod
- def generate_test_dashboard(cls):
- return {
- "label": f"{uuid4()}",
- "url": random_url(),
- }
+class TestEnterpriseDashboardSerializer(TestCase):
+ """High-level tests for EnterpriseDashboardSerializer"""
@classmethod
def generate_test_data(cls):
return {
- "availableDashboards": [
- cls.generate_test_dashboard() for _ in range(randint(0, 3))
- ],
- "mostRecentDashboard": cls.generate_test_dashboard()
- if random_bool()
- else None,
+ "uuid": str(uuid4()),
+ "name": str(uuid4()),
}
def test_structure(self):
"""Test that nothing breaks and the output fields look correct"""
input_data = self.generate_test_data()
- output_data = EnterpriseDashboardsSerializer(input_data).data
+ output_data = EnterpriseDashboardSerializer(input_data).data
expected_keys = [
- "availableDashboards",
- "mostRecentDashboard",
+ "label",
+ "url",
]
assert output_data.keys() == set(expected_keys)
@@ -597,13 +861,13 @@ def test_happy_path(self):
input_data = self.generate_test_data()
- output_data = EnterpriseDashboardsSerializer(input_data).data
+ output_data = EnterpriseDashboardSerializer(input_data).data
self.assertDictEqual(
output_data,
{
- "availableDashboards": input_data["availableDashboards"],
- "mostRecentDashboard": input_data["mostRecentDashboard"],
+ "label": input_data["name"],
+ "url": settings.ENTERPRISE_LEARNER_PORTAL_BASE_URL + '/' + input_data["uuid"],
},
)
@@ -619,7 +883,7 @@ def test_empty(self):
input_data = {
"emailConfirmation": None,
- "enterpriseDashboards": None,
+ "enterpriseDashboard": None,
"platformSettings": None,
"enrollments": [],
"unfulfilledEntitlements": [],
@@ -631,7 +895,7 @@ def test_empty(self):
output_data,
{
"emailConfirmation": None,
- "enterpriseDashboards": None,
+ "enterpriseDashboard": None,
"platformSettings": None,
"courses": [],
"suggestedCourses": [],
@@ -657,7 +921,7 @@ def test_enrollments(self):
input_data = {
"emailConfirmation": None,
- "enterpriseDashboards": None,
+ "enterpriseDashboard": None,
"platformSettings": None,
"enrollments": enrollments,
"unfulfilledEntitlements": [],
@@ -689,7 +953,7 @@ def test_enrollments(self):
"lms.djangoapps.learner_home.serializers.PlatformSettingsSerializer.to_representation"
)
@mock.patch(
- "lms.djangoapps.learner_home.serializers.EnterpriseDashboardsSerializer.to_representation"
+ "lms.djangoapps.learner_home.serializers.EnterpriseDashboardSerializer.to_representation"
)
@mock.patch(
"lms.djangoapps.learner_home.serializers.EmailConfirmationSerializer.to_representation"
@@ -697,7 +961,7 @@ def test_enrollments(self):
def test_linkage(
self,
mock_email_confirmation_serializer,
- mock_enterprise_dashboards_serializer,
+ mock_enterprise_dashboard_serializer,
mock_platform_settings_serializer,
mock_learner_enrollment_serializer,
mock_entitlements_serializer,
@@ -706,8 +970,8 @@ def test_linkage(
mock_email_confirmation_serializer.return_value = (
mock_email_confirmation_serializer
)
- mock_enterprise_dashboards_serializer.return_value = (
- mock_enterprise_dashboards_serializer
+ mock_enterprise_dashboard_serializer.return_value = (
+ mock_enterprise_dashboard_serializer
)
mock_platform_settings_serializer.return_value = (
mock_platform_settings_serializer
@@ -720,7 +984,7 @@ def test_linkage(
input_data = {
"emailConfirmation": {},
- "enterpriseDashboards": [{}],
+ "enterpriseDashboard": {},
"platformSettings": {},
"enrollments": [{}],
"unfulfilledEntitlements": [{}],
@@ -732,7 +996,7 @@ def test_linkage(
output_data,
{
"emailConfirmation": mock_email_confirmation_serializer,
- "enterpriseDashboards": mock_enterprise_dashboards_serializer,
+ "enterpriseDashboard": mock_enterprise_dashboard_serializer,
"platformSettings": mock_platform_settings_serializer,
"courses": [
mock_learner_enrollment_serializer,
diff --git a/lms/djangoapps/learner_home/test_utils.py b/lms/djangoapps/learner_home/test_utils.py
index 68169daf8198..dea168290faf 100644
--- a/lms/djangoapps/learner_home/test_utils.py
+++ b/lms/djangoapps/learner_home/test_utils.py
@@ -6,6 +6,14 @@
from time import time
from uuid import uuid4
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.tests.factories import CourseModeFactory
+from common.djangoapps.student.tests.factories import CourseEnrollmentFactory
+from openedx.core.djangoapps.content.course_overviews.tests.factories import (
+ CourseOverviewFactory,
+)
+from xmodule.modulestore.tests.factories import CourseFactory
+
def random_bool():
"""Test util for generating a random boolean"""
@@ -48,3 +56,27 @@ def datetime_to_django_format(datetime_obj):
"""Util for matching serialized Django datetime format for comparison"""
if datetime_obj:
return datetime_obj.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def create_test_enrollment(user, course_mode=CourseMode.AUDIT):
+ """Create a test user, course, course overview, and enrollment. Return the enrollment."""
+ course = CourseFactory(self_paced=True)
+
+ CourseModeFactory(
+ course_id=course.id,
+ mode_slug=course_mode,
+ )
+
+ course_overview = CourseOverviewFactory(id=course.id)
+
+ # extra info for exercising serializers
+ course_overview.certificate_available_date = random_date()
+
+ test_enrollment = CourseEnrollmentFactory(
+ course_id=course.id, mode=course_mode, user_id=user.id
+ )
+
+ test_enrollment.course_overview.marketing_url = random_url()
+ test_enrollment.course_overview.end = random_date()
+
+ return test_enrollment
diff --git a/lms/djangoapps/learner_home/test_views.py b/lms/djangoapps/learner_home/test_views.py
index c5a60ab3edc5..62a0db9db6b1 100644
--- a/lms/djangoapps/learner_home/test_views.py
+++ b/lms/djangoapps/learner_home/test_views.py
@@ -6,11 +6,12 @@
from uuid import uuid4
import ddt
+from django.conf import settings
from django.urls import reverse
from rest_framework.test import APITestCase
+from lms.djangoapps.learner_home.test_utils import create_test_enrollment
from common.djangoapps.course_modes.models import CourseMode
-from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import (
CourseEnrollmentFactory,
UserFactory,
@@ -30,6 +31,9 @@
from xmodule.modulestore.tests.factories import CourseFactory
+ENTERPRISE_ENABLED = 'ENABLE_ENTERPRISE_INTEGRATION'
+
+
class TestGetPlatformSettings(TestCase):
"""Tests for get_platform_settings"""
@@ -126,22 +130,9 @@ def setUp(self):
super().setUp()
self.user = UserFactory()
- def create_test_enrollment(self, course_mode=CourseMode.AUDIT):
- """Create a course and enrollment for the test user. Returns a CourseEnrollment"""
- course = CourseFactory(self_paced=True)
-
- CourseModeFactory(
- course_id=course.id,
- mode_slug=course_mode,
- )
-
- return CourseEnrollmentFactory(
- course_id=course.id, mode=course_mode, user_id=self.user.id
- )
-
def test_basic(self):
# Given a set of enrollments
- test_enrollments = [self.create_test_enrollment() for i in range(3)]
+ test_enrollments = [create_test_enrollment(self.user) for i in range(3)]
# When I request my enrollments
returned_enrollments, course_mode_info = get_enrollments(self.user, None, None)
@@ -229,6 +220,7 @@ def setUp(self):
super().setUp()
self.log_in()
+ @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False)
def test_response_structure(self):
"""Basic test for correct response structure"""
@@ -245,7 +237,7 @@ def test_response_structure(self):
expected_keys = set(
[
"emailConfirmation",
- "enterpriseDashboards",
+ "enterpriseDashboard",
"platformSettings",
"courses",
"suggestedCourses",
@@ -254,6 +246,7 @@ def test_response_structure(self):
assert expected_keys == response_data.keys()
+ @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False)
@patch("lms.djangoapps.learner_home.views.get_user_account_confirmation_info")
def test_email_confirmation(self, mock_user_conf_info):
"""Test that email confirmation info passes through correctly"""
@@ -281,3 +274,44 @@ def test_email_confirmation(self, mock_user_conf_info):
"sendEmailUrl": mock_user_conf_info_response["sendEmailUrl"],
},
)
+
+ @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False)
+ @patch("lms.djangoapps.learner_home.views.cert_info")
+ def test_get_cert_statuses(self, mock_get_cert_info):
+ """Test that cert information gets loaded correctly"""
+
+ # Given I am logged in
+ self.log_in()
+
+ # (and we have tons of mocks to avoid integration tests)
+ mock_enrollment = create_test_enrollment(
+ self.user, course_mode=CourseMode.VERIFIED
+ )
+ mock_cert_info = {
+ "status": "downloadable",
+ "mode": "verified",
+ "linked_in_url": None,
+ "show_survey_button": False,
+ "can_unenroll": True,
+ "show_cert_web_view": True,
+ "cert_web_view_url": random_url(),
+ }
+ mock_get_cert_info.return_value = mock_cert_info
+
+ # When I request the dashboard
+ response = self.client.get(self.view_url)
+
+ # Then I get the expected success response
+ assert response.status_code == 200
+ response_data = json.loads(response.content)
+
+ self.assertDictEqual(
+ response_data["courses"][0]["certificate"],
+ {
+ "availableDate": mock_enrollment.course.certificate_available_date,
+ "isRestricted": False,
+ "isEarned": True,
+ "isDownloadable": True,
+ "certPreviewUrl": mock_cert_info["cert_web_view_url"],
+ },
+ )
diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py
index c5de2d5c1e06..79aa288e9bc8 100644
--- a/lms/djangoapps/learner_home/views.py
+++ b/lms/djangoapps/learner_home/views.py
@@ -8,17 +8,25 @@
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.edxmako.shortcuts import marketing_link
-from common.djangoapps.student.helpers import get_resume_urls_for_enrollments
+from common.djangoapps.student.helpers import cert_info, get_resume_urls_for_enrollments
from common.djangoapps.student.views.dashboard import (
complete_course_mode_info,
get_course_enrollments,
get_org_black_and_whitelist_for_site,
)
+from common.djangoapps.util.milestones_helpers import (
+ get_pre_requisite_courses_not_completed,
+)
from lms.djangoapps.bulk_email.models import Optout
from lms.djangoapps.bulk_email.models_api import is_bulk_email_feature_enabled
from lms.djangoapps.commerce.utils import EcommerceService
+from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user
+from lms.djangoapps.courseware.access_utils import (
+ check_course_open_for_learner,
+)
from lms.djangoapps.learner_home.serializers import LearnerDashboardSerializer
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
+from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data
def get_platform_settings():
@@ -117,6 +125,70 @@ def get_ecommerce_payment_page(user):
)
+def get_cert_statuses(user, course_enrollments):
+ """Get cert status by course for user enrollments"""
+ return {
+ enrollment.course_id: cert_info(user, enrollment)
+ for enrollment in course_enrollments
+ }
+
+
+def _get_courses_with_unmet_prerequisites(user, course_enrollments):
+ """
+ Determine which courses have unmet prerequisites.
+ NOTE: that courses w/out prerequisites, or with met prerequisites are not returned
+ in the output dict. That way we can do a simple "course_id in dict" check.
+
+ Returns: {
+ : { "courses": [listing of unmet prerequisites] }
+ }
+ """
+
+ courses_having_prerequisites = frozenset(
+ enrollment.course_id
+ for enrollment in course_enrollments
+ if enrollment.course_overview.pre_requisite_courses
+ )
+
+ return get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
+
+
+def check_course_access(user, course_enrollments):
+ """
+ Wrapper for checks surrounding user ability to view courseware
+
+ Returns: {
+ : {
+ "has_unmet_prerequisites": True/False,
+ "is_too_early_to_view": True/False,
+ "user_has_staff_access": True/False
+ }
+ }
+ """
+
+ course_access_dict = {}
+
+ courses_with_unmet_prerequisites = _get_courses_with_unmet_prerequisites(
+ user, course_enrollments
+ )
+
+ for course_enrollment in course_enrollments:
+ course_access_dict[course_enrollment.course_id] = {
+ "has_unmet_prerequisites": course_enrollment.course_id
+ in courses_with_unmet_prerequisites,
+ "is_too_early_to_view": not check_course_open_for_learner(
+ user, course_enrollment.course
+ ),
+ "user_has_staff_access": any(
+ administrative_accesses_to_course_for_user(
+ user, course_enrollment.course_id
+ )
+ ),
+ }
+
+ return course_access_dict
+
+
class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument
"""List of courses a user is enrolled in or entitled to"""
@@ -125,6 +197,9 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
user = request.user
email_confirmation = get_user_account_confirmation_info(user)
+ # Gather info for enterprise dashboard
+ enterprise_customer = enterprise_customer_from_session_or_learner_data(request)
+
# Get the org whitelist or the org blacklist for the current site
site_org_whitelist, site_org_blacklist = get_org_black_and_whitelist_for_site()
@@ -141,14 +216,14 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
user, course_enrollments
)
- # TODO - Get verification status by course (do we still need this?)
+ # Get cert status by course
+ cert_statuses = get_cert_statuses(user, course_enrollments)
- # TODO - Determine view access for courses (for showing courseware link or not)
+ # Determine view access for course, (for showing courseware link) involves:
+ course_access_checks = check_course_access(user, course_enrollments)
# TODO - Get related programs
- # TODO - Get user verification status
-
# e-commerce info
ecommerce_payment_page = get_ecommerce_payment_page(user)
@@ -157,7 +232,7 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
learner_dash_data = {
"emailConfirmation": email_confirmation,
- "enterpriseDashboards": None,
+ "enterpriseDashboard": enterprise_customer,
"platformSettings": get_platform_settings(),
"enrollments": course_enrollments,
"unfulfilledEntitlements": [],
@@ -166,8 +241,10 @@ def get(self, request, *args, **kwargs): # pylint: disable=unused-argument
context = {
"ecommerce_payment_page": ecommerce_payment_page,
+ "cert_statuses": cert_statuses,
"course_mode_info": course_mode_info,
"course_optouts": course_optouts,
+ "course_access_checks": course_access_checks,
"resume_course_urls": resume_button_urls,
"show_email_settings_for": show_email_settings_for,
}
diff --git a/lms/static/js/learner_dashboard/RecommendationsPanel.jsx b/lms/static/js/learner_dashboard/RecommendationsPanel.jsx
index f98a9777a142..bfeb174ff55d 100644
--- a/lms/static/js/learner_dashboard/RecommendationsPanel.jsx
+++ b/lms/static/js/learner_dashboard/RecommendationsPanel.jsx
@@ -40,8 +40,11 @@ class RecommendationsPanel extends React.Component {
if (response.status === 400) {
return this.props.generalRecommendations;
} else {
+ if (window.hj) {
+ window.hj('event', 'van_1046_show_recommendations_survey');
+ }
return response.json();
-
+
}
}).catch(() => {
return this.props.generalRecommendations;
diff --git a/lms/templates/discussion/_discussion_inline_studio.html b/lms/templates/discussion/_discussion_inline_studio.html
index be1fe3353343..9433b6e7d5bd 100644
--- a/lms/templates/discussion/_discussion_inline_studio.html
+++ b/lms/templates/discussion/_discussion_inline_studio.html
@@ -7,6 +7,9 @@
${_("To view live discussions, click Preview or View Live in Unit Settings.")}
${_("Discussion ID: {discussion_id}").format(discussion_id=discussion_id)}
+ % if not is_visible:
+
${_('The discussion block is disabled for this course as it is not using a compatible discussion provider.')}
+ % endif
diff --git a/openedx/core/djangoapps/auth_exchange/README.rst b/openedx/core/djangoapps/auth_exchange/README.rst
index ca73d71b7983..45c3592d45b6 100644
--- a/openedx/core/djangoapps/auth_exchange/README.rst
+++ b/openedx/core/djangoapps/auth_exchange/README.rst
@@ -10,4 +10,4 @@ The following are currently implemented:
2. LoginWithAccessTokenView
- 1st party (open-edx) OAuth 2.0 access token -> session cookie
+ 1st party (open-edx) OAuth 2.0 access token (bearer/jwt) -> session cookie
diff --git a/openedx/core/djangoapps/auth_exchange/tests/test_views.py b/openedx/core/djangoapps/auth_exchange/tests/test_views.py
index c303837cf258..9d8f21e6eed5 100644
--- a/openedx/core/djangoapps/auth_exchange/tests/test_views.py
+++ b/openedx/core/djangoapps/auth_exchange/tests/test_views.py
@@ -7,16 +7,21 @@
import json
import unittest
from datetime import timedelta
+from datetime import datetime
import ddt
import httpretty
from django.conf import settings
from django.test import TestCase
+from django.test.utils import override_settings
from django.urls import reverse
+from freezegun import freeze_time
from oauth2_provider.models import Application
from rest_framework.test import APIClient
from social_django.models import Partial
+from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api
+from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter
from openedx.core.djangoapps.oauth_dispatch.tests import factories as dot_factories
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.third_party_auth.tests.utils import (
@@ -150,12 +155,15 @@ def setUp(self):
self.user = UserFactory()
self.oauth2_client = Application.objects.create(client_type=Application.CLIENT_CONFIDENTIAL)
- def _verify_response(self, access_token, expected_status_code, expected_cookie_name=None):
+ def _get_response(self, access_token, token_type='Bearer'):
+ url = reverse("login_with_access_token")
+ return self.client.post(url, HTTP_AUTHORIZATION=f"{token_type} {access_token}".encode('utf-8'))
+
+ def _verify_response(self, access_token, expected_status_code, token_type='Bearer', expected_cookie_name=None):
"""
Calls the login_with_access_token endpoint and verifies the response given the expected values.
"""
- url = reverse("login_with_access_token")
- response = self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {access_token}".encode('utf-8'))
+ response = self._get_response(access_token, token_type)
assert response.status_code == expected_status_code
if expected_cookie_name:
assert expected_cookie_name in response.cookies
@@ -167,16 +175,85 @@ def _create_dot_access_token(self, grant_type='Client credentials'):
dot_application = dot_factories.ApplicationFactory(user=self.user, authorization_grant_type=grant_type)
return dot_factories.AccessTokenFactory(user=self.user, application=dot_application)
- def test_invalid_token(self):
+ def test_failure_with_invalid_token(self):
self._verify_response("invalid_token", expected_status_code=401)
- assert 'session_key' not in self.client.session
+ assert '_auth_user_id' not in self.client.session
- def test_dot_password_grant_supported(self):
+ def test_success_with_dot_password_grant_supported(self):
access_token = self._create_dot_access_token(grant_type='password')
self._verify_response(access_token, expected_status_code=204, expected_cookie_name='sessionid')
assert int(self.client.session['_auth_user_id']) == self.user.id
- def test_dot_client_credentials_unsupported(self):
+ def test_failure_with_dot_client_credentials_unsupported(self):
access_token = self._create_dot_access_token()
self._verify_response(access_token, expected_status_code=401)
+
+ def _create_jwt_token(self, grant_type='password', scope='email profile', use_asymmetric_key=True):
+ """
+ Create jwt token
+ """
+ access_token = self._create_dot_access_token(grant_type)
+ oauth_adapter = DOTAdapter()
+ token_dict = {
+ 'access_token': access_token,
+ 'scope': scope,
+ }
+ jwt_token = jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=use_asymmetric_key)
+ return jwt_token
+
+ def test_failure_with_invalid_jwt(self):
+ self._verify_response("invalid_token", token_type="JWT", expected_status_code=401)
+
+ assert '_auth_user_id' not in self.client.session
+
+ def test_failure_with_valid_jwt_but_not_password_grant(self):
+
+ jwt_token = self._create_jwt_token(grant_type='Client credentials')
+ self._verify_response(jwt_token, token_type="JWT", expected_status_code=401)
+
+ assert '_auth_user_id' not in self.client.session
+
+ def test_failure_with_expired_jwt_with_password_grant(self):
+ with override_settings(JWT_ACCESS_TOKEN_EXPIRE_SECONDS=1):
+ jwt_token = self._create_jwt_token()
+
+ # freeze_time will pretend 10 seconds have passed!
+ with freeze_time(lambda: datetime.utcnow() + timedelta(seconds=10)):
+ self._verify_response(jwt_token, token_type="JWT", expected_status_code=401)
+
+ assert '_auth_user_id' not in self.client.session
+
+ def test_failure_with_valid_symmetric_jwt(self):
+
+ jwt_token = self._create_jwt_token(use_asymmetric_key=False)
+ response = self._get_response(jwt_token, token_type="JWT")
+ error_msg = {
+ 'error_code': 'non_asymmetric_token',
+ 'developer_message': 'Only asymmetric jwt are supported.'
+ }
+ self.assertDictEqual(error_msg, json.loads(response.content))
+ assert '_auth_user_id' not in self.client.session
+ assert response.status_code == 401
+
+ def test_failure_with_valid_asymmetric_jwt_and_disabled_user(self):
+ jwt_token = self._create_jwt_token()
+ self.user.set_unusable_password()
+ self.user.save()
+
+ response = self._get_response(jwt_token, token_type="JWT")
+ error_msg = {
+ 'error_code': 'account_disabled',
+ 'developer_message': 'User account is disabled.'
+ }
+ self.assertDictEqual(error_msg, json.loads(response.content))
+ assert '_auth_user_id' not in self.client.session
+ assert response.status_code == 401
+
+ def test_success_with_valid_asymmetric_jwt(self):
+
+ jwt_token = self._create_jwt_token()
+ self._verify_response(jwt_token, token_type="JWT",
+ expected_status_code=204, expected_cookie_name='sessionid')
+
+ assert int(self.client.session['_auth_user_id']) == self.user.id
diff --git a/openedx/core/djangoapps/auth_exchange/views.py b/openedx/core/djangoapps/auth_exchange/views.py
index 054408e0c0c6..e4b302595277 100644
--- a/openedx/core/djangoapps/auth_exchange/views.py
+++ b/openedx/core/djangoapps/auth_exchange/views.py
@@ -6,6 +6,9 @@
2. LoginWithAccessTokenView:
1st party (open-edx) OAuth 2.0 access token -> session cookie
"""
+import logging
+
+import jwt
import django.contrib.auth as auth
import social_django.utils as social_utils
from django.conf import settings
@@ -13,6 +16,12 @@
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
+from edx_rest_framework_extensions.auth.jwt.authentication import (
+ JwtAuthentication,
+ get_decoded_jwt_from_auth,
+ is_jwt_authenticated,
+)
+from edx_rest_framework_extensions.auth.jwt.decoder import get_asymmetric_only_jwt_decode_handler
from oauth2_provider import models as dot_models
from oauth2_provider.views.base import TokenView as DOTAccessTokenView
from rest_framework import permissions
@@ -27,6 +36,9 @@
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
+logger = logging.getLogger(__name__)
+
+
class AccessTokenExchangeBase(APIView):
"""
View for token exchange from 3rd party OAuth access token to 1st party
@@ -106,7 +118,7 @@ class LoginWithAccessTokenView(APIView):
"""
View for exchanging an access token for session cookies
"""
- authentication_classes = (BearerAuthenticationAllowInactiveUser,)
+ authentication_classes = (BearerAuthenticationAllowInactiveUser, JwtAuthentication)
permission_classes = (permissions.IsAuthenticated,)
@staticmethod
@@ -120,16 +132,52 @@ def _get_path_of_arbitrary_backend_for_user(user):
return backend_path
@staticmethod
- def _is_grant_password(access_token):
+ def _ensure_access_token_has_password_grant(request):
+ """
+ Ensures the access token provided has password type grant.
+ """
+ if is_jwt_authenticated(request):
+ jwt_payload = get_decoded_jwt_from_auth(request)
+ if jwt_payload['grant_type'] == dot_models.Application.GRANT_PASSWORD:
+ return
+ else:
+ token_query = dot_models.AccessToken.objects.select_related('user')
+ dot_token = token_query.filter(token=request.auth).first()
+ if dot_token and dot_token.application.authorization_grant_type == dot_models.Application.GRANT_PASSWORD:
+ return
+
+ raise AuthenticationFailed({
+ 'error_code': 'non_supported_token',
+ 'developer_message': 'Only access tokens with grant type password are supported.'
+ })
+
+ @staticmethod
+ def _ensure_jwt_is_asymmetric(request):
"""
- Check if the access token provided is DOT based and has password type grant.
+ Ensures the provided jwt token is asymmetric.
"""
- token_query = dot_models.AccessToken.objects.select_related('user')
- dot_token = token_query.filter(token=access_token).first()
- if dot_token and dot_token.application.authorization_grant_type == dot_models.Application.GRANT_PASSWORD:
- return True
+ if is_jwt_authenticated(request):
+ try:
+ get_asymmetric_only_jwt_decode_handler(request.auth)
+ except jwt.InvalidTokenError as symmetric_jwt_error:
+ error_msg = {
+ 'error_code': 'non_asymmetric_token',
+ 'developer_message': 'Only asymmetric jwt are supported.'
+ }
+ raise AuthenticationFailed(error_msg) from symmetric_jwt_error
- return False
+ @staticmethod
+ def _ensure_user_is_not_disabled(request):
+ """
+ Ensures the user requesting for session cookies is not disabled.
+ """
+ if not request.user.has_usable_password():
+ logger.info('Session creation failed because user account %s was disabled', request.user.id)
+ error_msg = {
+ 'error_code': 'account_disabled',
+ 'developer_message': 'User account is disabled.'
+ }
+ raise AuthenticationFailed(error_msg)
@method_decorator(csrf_exempt)
def post(self, request):
@@ -145,11 +193,9 @@ def post(self, request):
if not hasattr(request.user, 'backend'):
request.user.backend = self._get_path_of_arbitrary_backend_for_user(request.user)
- if not self._is_grant_password(request.auth):
- raise AuthenticationFailed({
- 'error_code': 'non_supported_token',
- 'developer_message': 'Only support DOT type access token with grant type password. '
- })
+ self._ensure_user_is_not_disabled(request)
+ self._ensure_access_token_has_password_grant(request)
+ self._ensure_jwt_is_asymmetric(request)
login(request, request.user) # login generates and stores the user's cookies in the session
response = HttpResponse(status=204) # cookies stored in the session are returned with the response
diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py
index ad545b34c072..6f4d51ad356b 100644
--- a/openedx/core/djangoapps/catalog/utils.py
+++ b/openedx/core/djangoapps/catalog/utils.py
@@ -741,7 +741,7 @@ def get_programs_for_organization(organization):
return cache.get(PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL.format(org_key=organization))
-def get_course_data(course_key_str):
+def get_course_data(course_key_str, fields):
"""
Retrieve information about the course with the given course key.
@@ -766,6 +766,7 @@ def get_course_data(course_key_str):
cache_key=course_cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False,
+ fields=fields
)
if data:
return data
diff --git a/openedx/core/djangoapps/course_live/serializers.py b/openedx/core/djangoapps/course_live/serializers.py
index 648d3f8d4030..b018b7d04b25 100644
--- a/openedx/core/djangoapps/course_live/serializers.py
+++ b/openedx/core/djangoapps/course_live/serializers.py
@@ -32,7 +32,7 @@ def validate_lti_config(self, value):
"""
Validates if lti_config contains all required data i.e. custom_instructor_email
"""
- additional_parameters = value.get('additional_parameters', None)
+ additional_parameters = value.get('additional_parameters', {})
custom_instructor_email = additional_parameters.get('custom_instructor_email', None)
requires_email = self.context.get('provider').requires_custom_email()
@@ -118,9 +118,14 @@ def __init__(self, *args, **kwargs):
self.context['provider_type'] = self.data.get('provider_type', '')
def validate_free_tier(self, value):
- if value == self.context['provider'].has_free_tier:
- return value
- raise serializers.ValidationError('Provider does not support free tier')
+ """
+ Validates free_tier attribute
+ """
+ if value:
+ if value == self.context['provider'].has_free_tier:
+ return value
+ raise serializers.ValidationError('Provider does not support free tier')
+ return value
def get_pii_sharing_allowed(self, instance):
return self.context['pii_sharing_allowed']
@@ -140,6 +145,8 @@ def create(self, validated_data):
instance = self._update_course_live_instance(instance, validated_data)
if not validated_data.get('free_tier', False):
instance = self._update_lti(instance, lti_config)
+ else:
+ instance.lti_configuration = None
instance.save()
return instance
@@ -151,6 +158,8 @@ def update(self, instance: CourseLiveConfiguration, validated_data: dict) -> Cou
instance = self._update_course_live_instance(instance, validated_data)
if not validated_data.get('free_tier', False):
instance = self._update_lti(instance, lti_config)
+ else:
+ instance.lti_configuration = None
instance.save()
return instance
diff --git a/openedx/core/djangoapps/course_live/tests/test_views.py b/openedx/core/djangoapps/course_live/tests/test_views.py
index 5ec6dc687440..7f9e2e79c067 100644
--- a/openedx/core/djangoapps/course_live/tests/test_views.py
+++ b/openedx/core/djangoapps/course_live/tests/test_views.py
@@ -63,6 +63,9 @@ def create_course_live_config(self, provider='zoom'):
}
},
}
+ if not providers.get(provider).additional_parameters:
+ lti_config.pop('lti_config')
+
course_live_config_data = {
'enabled': True,
'provider_type': provider,
@@ -125,8 +128,7 @@ def test_create_configurations_data(self, provider, share_email, share_username)
"""
lti_config, data, response = self.create_course_live_config(provider)
course_live_configurations = CourseLiveConfiguration.get(self.course.id)
- lti_configuration = CourseLiveConfiguration.get(self.course.id).lti_configuration
-
+ lti_configuration = course_live_configurations.get(self.course.id).lti_configuration
self.assertEqual(self.course.id, course_live_configurations.course_key)
self.assertEqual(data['enabled'], course_live_configurations.enabled)
self.assertEqual(data['provider_type'], course_live_configurations.provider_type)
@@ -134,10 +136,16 @@ def test_create_configurations_data(self, provider, share_email, share_username)
self.assertEqual(lti_config['lti_1p1_client_key'], lti_configuration.lti_1p1_client_key)
self.assertEqual(lti_config['lti_1p1_client_secret'], lti_configuration.lti_1p1_client_secret)
self.assertEqual(lti_config['lti_1p1_launch_url'], lti_configuration.lti_1p1_launch_url)
+
+ provider_instance = ProviderManager().get_enabled_providers().get(provider)
+ additional_param = {'additional_parameters': {}}
+ if provider_instance.additional_parameters:
+ additional_param = {'additional_parameters': {'custom_instructor_email': 'email@example.com'}}
+
self.assertEqual({
'pii_share_username': share_username,
'pii_share_email': share_email,
- 'additional_parameters': {'custom_instructor_email': 'email@example.com'}
+ **additional_param
}, lti_configuration.lti_config)
self.assertEqual(response.status_code, 200)
@@ -149,6 +157,12 @@ def test_create_configurations_response(self, provider, share_email, share_usern
Create and test POST request response data
"""
lti_config, course_live_config_data, response = self.create_course_live_config(provider)
+
+ provider_instance = ProviderManager().get_enabled_providers().get(provider)
+ additional_param = {'additional_parameters': {}}
+ if provider_instance.additional_parameters:
+ additional_param = {'additional_parameters': {'custom_instructor_email': 'email@example.com'}}
+
expected_data = {
'course_key': str(self.course.id),
'enabled': True,
@@ -163,9 +177,7 @@ def test_create_configurations_response(self, provider, share_email, share_usern
'lti_config': {
'pii_share_email': share_email,
'pii_share_username': share_username,
- 'additional_parameters': {
- 'custom_instructor_email': 'email@example.com'
- },
+ **additional_param
},
},
}
@@ -198,6 +210,12 @@ def test_update_configurations_response(self, provider, share_email, share_usern
response = self._post(updated_data)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
+
+ provider_instance = ProviderManager().get_enabled_providers().get(provider)
+ additional_param = {'additional_parameters': {}}
+ if provider_instance.additional_parameters:
+ additional_param = {'additional_parameters': {'custom_instructor_email': 'new_email@example.com'}}
+
expected_data = {
'course_key': str(self.course.id),
'provider_type': provider,
@@ -211,10 +229,7 @@ def test_update_configurations_response(self, provider, share_email, share_usern
'lti_config': {
'pii_share_username': share_username,
'pii_share_email': share_email,
- 'additional_parameters': {
- 'custom_instructor_email':
- 'new_email@example.com'
- }
+ **additional_param
}
},
'pii_sharing_allowed': share_email or share_username
@@ -271,6 +286,7 @@ def test_create_configurations_response_free_tier(self, provider, share_email, s
"""
Create and test POST request response data
"""
+ self.create_course_live_config()
providers = ProviderManager().get_enabled_providers()
if providers.get(provider).requires_pii_sharing():
CourseAllowPIISharingInLTIFlag.objects.create(course_id=self.course.id, enabled=True)
diff --git a/openedx/core/djangoapps/course_live/views.py b/openedx/core/djangoapps/course_live/views.py
index 8cff28b9202a..d148e9301897 100644
--- a/openedx/core/djangoapps/course_live/views.py
+++ b/openedx/core/djangoapps/course_live/views.py
@@ -120,7 +120,9 @@ def post(self, request, course_id: str) -> Response:
"pii_sharing_allowed": pii_sharing_allowed,
"message": "PII sharing is not allowed on this course"
})
-
+ if provider and not provider.additional_parameters and request.data.get('lti_configuration', False):
+ # Add empty lti config if none is provided in case additional params are not required
+ request.data['lti_configuration']['lti_config'] = {'additional_parameters': {}}
configuration = CourseLiveConfiguration.get(course_id)
serializer = CourseLiveConfigurationSerializer(
configuration,
diff --git a/openedx/core/djangoapps/discussions/tests/test_views.py b/openedx/core/djangoapps/discussions/tests/test_views.py
index 6c0b99c1599a..bc2f3e8489f9 100644
--- a/openedx/core/djangoapps/discussions/tests/test_views.py
+++ b/openedx/core/djangoapps/discussions/tests/test_views.py
@@ -424,7 +424,10 @@ def test_available_providers_staff(self, current_provider, new_structure_enabled
with override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, new_structure_enabled):
response = self._get()
data = response.json()
- for visible_provider in [Provider.OPEN_EDX, Provider.LEGACY]:
+ visible_providers = [Provider.OPEN_EDX, Provider.LEGACY]
+ if not new_structure_enabled:
+ visible_providers = [Provider.LEGACY]
+ for visible_provider in visible_providers:
assert visible_provider in data['providers']['available'].keys()
@ddt.data(
diff --git a/openedx/core/djangoapps/discussions/views.py b/openedx/core/djangoapps/discussions/views.py
index 1045b1b261de..6193faf46c5b 100644
--- a/openedx/core/djangoapps/discussions/views.py
+++ b/openedx/core/djangoapps/discussions/views.py
@@ -183,6 +183,11 @@ def get_provider_data(course_key_string: str, show_all: bool = False) -> Dict:
else:
if configuration.provider_type != Provider.OPEN_EDX:
hidden_providers.append(Provider.OPEN_EDX)
+ else:
+ # if new discussions is not enabled, hide the new provider in case it is not already in use
+ if not ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled(course_key):
+ if configuration.provider_type != Provider.OPEN_EDX:
+ hidden_providers.append(Provider.OPEN_EDX)
serializer = DiscussionsProvidersSerializer(
{
diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
index 47145d2d9d0d..76a6209abced 100644
--- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
+++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py
@@ -108,14 +108,15 @@ def setUp(self):
)
models.RestrictedApplication.objects.create(application=self.restricted_dot_app)
- def _post_request(self, user, client, token_type=None, scope=None, headers=None):
+ def _post_request(self, user, client, token_type=None, scope=None, headers=None, asymmetric_jwt=False):
"""
Call the view with a POST request object with the appropriate format,
returning the response object.
"""
- return self.client.post(self.url, self._post_body(user, client, token_type, scope), **(headers or {})) # pylint: disable=no-member
+ post_body = self._post_body(user, client, token_type, scope, asymmetric_jwt=asymmetric_jwt)
+ return self.client.post(self.url, post_body, **(headers or {})) # pylint: disable=no-member
- def _post_body(self, user, client, token_type=None, scope=None):
+ def _post_body(self, user, client, token_type=None, scope=None, asymmetric_jwt=False):
"""
Return a dictionary to be used as the body of the POST request
"""
@@ -133,7 +134,7 @@ def setUp(self):
self.url = reverse('access_token')
self.view_class = views.AccessTokenView
- def _post_body(self, user, client, token_type=None, scope=None):
+ def _post_body(self, user, client, token_type=None, scope=None, asymmetric_jwt=None):
"""
Return a dictionary to be used as the body of the POST request
"""
@@ -155,6 +156,9 @@ def _post_body(self, user, client, token_type=None, scope=None):
if scope:
body['scope'] = scope
+ if asymmetric_jwt:
+ body['asymmetric_jwt'] = asymmetric_jwt
+
return body
def _generate_key_pair(self):
@@ -171,12 +175,13 @@ def _generate_key_pair(self):
return serialized_public_keys_json, serialized_keypair_json
- def _test_jwt_access_token(self, client_attr, token_type=None, headers=None, grant_type=None):
+ def _test_jwt_access_token(self, client_attr, token_type=None, headers=None, grant_type=None, asymmetric_jwt=False):
"""
Test response for JWT token.
"""
client = getattr(self, client_attr)
- response = self._post_request(self.user, client, token_type=token_type, headers=headers or {})
+ response = self._post_request(self.user, client, token_type=token_type,
+ headers=headers or {}, asymmetric_jwt=asymmetric_jwt)
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
expected_default_expires_in = 60 * 60
@@ -189,6 +194,7 @@ def _test_jwt_access_token(self, client_attr, token_type=None, headers=None, gra
grant_type=grant_type,
should_be_restricted=False,
expires_in=expected_default_expires_in,
+ should_be_asymmetric_key=asymmetric_jwt
)
@ddt.data('dot_app')
@@ -337,10 +343,17 @@ def test_jwt_access_token_scopes_and_filters(self, grant_type):
grant_type=grant_type,
)
+ def test_asymmetric_jwt_access_token(self):
+ """
+ Verify the JWT is asymmetric when requested.
+ """
+ self._test_jwt_access_token('dot_app', token_type='jwt', grant_type='password', asymmetric_jwt=True)
+
@ddt.ddt
@httpretty.activate
-class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAuthTestMixin, _DispatchingViewTestCase):
+class TestAccessTokenExchangeView(ThirdPartyOAuthTestMixinGoogle, ThirdPartyOAuthTestMixin,
+ _DispatchingViewTestCase, mixins.AccessTokenMixin):
"""
Test class for AccessTokenExchangeView
"""
@@ -350,7 +363,7 @@ def setUp(self):
self.view_class = views.AccessTokenExchangeView
super().setUp()
- def _post_body(self, user, client, token_type=None, scope=None):
+ def _post_body(self, user, client, token_type=None, scope=None, asymmetric_jwt=None):
body = {
'client_id': client.client_id,
'access_token': self.access_token,
@@ -358,6 +371,9 @@ def _post_body(self, user, client, token_type=None, scope=None):
if token_type:
body['token_type'] = token_type
+ if asymmetric_jwt:
+ body['asymmetric_jwt'] = asymmetric_jwt
+
return body
@ddt.data('dot_app')
@@ -375,8 +391,34 @@ def test_jwt_access_token_exchange_calls_dispatched_view(self, client_attr):
self._setup_provider_response(success=True)
response = self._post_request(self.user, client, token_type='jwt')
assert response.status_code == 200
+ data = json.loads(response.content.decode('utf-8'))
+ self.assert_valid_jwt_access_token(
+ data['access_token'],
+ self.user,
+ data['scope'].split(' '),
+ grant_type='password'
+ )
+ assert 'expires_in' in data
+ assert data['expires_in'] > 0
+ assert data['token_type'] == 'JWT'
+
+ @ddt.data('dot_app')
+ def test_asymmetric_jwt_access_token_exchange_calls_dispatched_view(self, client_attr):
+ client = getattr(self, client_attr)
+ self.oauth_client = client
+ self._setup_provider_response(success=True)
+ response = self._post_request(self.user, client, token_type='jwt', asymmetric_jwt=True)
+ assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
+ self.assert_valid_jwt_access_token(
+ data['access_token'],
+ self.user,
+ data['scope'].split(' '),
+ grant_type='password',
+ should_be_asymmetric_key=True
+ )
+
assert 'expires_in' in data
assert data['expires_in'] > 0
assert data['token_type'] == 'JWT'
diff --git a/openedx/core/djangoapps/oauth_dispatch/views.py b/openedx/core/djangoapps/oauth_dispatch/views.py
index 0e9684e95b51..dbdebfae3ff2 100644
--- a/openedx/core/djangoapps/oauth_dispatch/views.py
+++ b/openedx/core/djangoapps/oauth_dispatch/views.py
@@ -117,7 +117,9 @@ def _get_jwt_content_from_access_token_content(self, request, response):
Includes the JWT token and token type in the response.
"""
opaque_token_dict = json.loads(response.content.decode('utf-8'))
- jwt_token_dict = create_jwt_token_dict(opaque_token_dict, self.get_adapter(request))
+ use_asymmetric_key = request.POST.get('asymmetric_jwt', False)
+ jwt_token_dict = create_jwt_token_dict(opaque_token_dict, self.get_adapter(request),
+ use_asymmetric_key=use_asymmetric_key)
return json.dumps(jwt_token_dict)
@@ -150,7 +152,9 @@ def _get_jwt_data_from_access_token_data(self, request, response):
Includes the JWT token and token type in the response.
"""
opaque_token_dict = response.data
- jwt_token_dict = create_jwt_token_dict(opaque_token_dict, self.get_adapter(request))
+ use_asymmetric_key = request.POST.get('asymmetric_jwt', False)
+ jwt_token_dict = create_jwt_token_dict(opaque_token_dict, self.get_adapter(request),
+ use_asymmetric_key=use_asymmetric_key)
return jwt_token_dict
diff --git a/openedx/core/djangoapps/user_api/accounts/__init__.py b/openedx/core/djangoapps/user_api/accounts/__init__.py
index b36f2cce68d9..84daeccdb8f2 100644
--- a/openedx/core/djangoapps/user_api/accounts/__init__.py
+++ b/openedx/core/djangoapps/user_api/accounts/__init__.py
@@ -52,7 +52,6 @@
# Translators: This message is shown to users who attempt to create a new account using
# an invalid email format.
-EMAIL_INVALID_MSG = _('"{email}" is not a valid email address.')
AUTHN_EMAIL_INVALID_MSG = _('Enter a valid email address')
# Translators: This message is shown to users who attempt to create a new
diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py
index 8cc013cb9425..4e1c6b426dbc 100644
--- a/openedx/core/djangoapps/user_api/accounts/api.py
+++ b/openedx/core/djangoapps/user_api/accounts/api.py
@@ -423,17 +423,16 @@ def get_username_validation_error(username):
return _validate(_validate_username, errors.AccountUsernameInvalid, username)
-def get_email_validation_error(email, api_version='v1'):
+def get_email_validation_error(email):
"""Get the built-in validation error message for when
the email is invalid in some way.
:param email: The proposed email (unicode).
- :param api_version: registration validation api version
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
- return _validate(_validate_email, errors.AccountEmailInvalid, email, api_version)
+ return _validate(_validate_email, errors.AccountEmailInvalid, email)
def get_secondary_email_validation_error(email):
@@ -487,30 +486,28 @@ def get_country_validation_error(country):
return _validate(_validate_country, errors.AccountCountryInvalid, country)
-def get_username_existence_validation_error(username, api_version='v1'):
+def get_username_existence_validation_error(username):
"""Get the built-in validation error message for when
the username has an existence conflict.
:param username: The proposed username (unicode).
- :param api_version: registration validation api version
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
- return _validate(_validate_username_doesnt_exist, errors.AccountUsernameAlreadyExists, username, api_version)
+ return _validate(_validate_username_doesnt_exist, errors.AccountUsernameAlreadyExists, username)
-def get_email_existence_validation_error(email, api_version='v1'):
+def get_email_existence_validation_error(email):
"""Get the built-in validation error message for when
the email has an existence conflict.
:param email: The proposed email (unicode).
- :param api_version: registration validation api version
:param default: The message to default to in case of no error.
:return: Validation error message.
"""
- return _validate(_validate_email_doesnt_exist, errors.AccountEmailAlreadyExists, email, api_version)
+ return _validate(_validate_email_doesnt_exist, errors.AccountEmailAlreadyExists, email)
def _get_user_and_profile(username):
@@ -577,12 +574,11 @@ def _validate_username(username):
raise errors.AccountUsernameInvalid(validation_err.message)
-def _validate_email(email, api_version='v1'):
+def _validate_email(email):
"""Validate the format of the email address.
Arguments:
email (unicode): The proposed email.
- api_version(str): Validation API version; it is used to determine the error message
Returns:
None
@@ -595,9 +591,7 @@ def _validate_email(email, api_version='v1'):
_validate_unicode(email)
_validate_type(email, str, accounts.EMAIL_BAD_TYPE_MSG)
_validate_length(email, accounts.EMAIL_MIN_LENGTH, accounts.EMAIL_MAX_LENGTH, accounts.EMAIL_BAD_LENGTH_MSG)
- validate_email.message = (
- accounts.EMAIL_INVALID_MSG.format(email=email) if api_version == 'v1' else accounts.AUTHN_EMAIL_INVALID_MSG
- )
+ validate_email.message = accounts.AUTHN_EMAIL_INVALID_MSG
validate_email(email)
except (UnicodeError, errors.AccountDataBadType, errors.AccountDataBadLength) as invalid_email_err:
raise errors.AccountEmailInvalid(str(invalid_email_err))
@@ -670,34 +664,26 @@ def _validate_country(country):
raise errors.AccountCountryInvalid(accounts.REQUIRED_FIELD_COUNTRY_MSG)
-def _validate_username_doesnt_exist(username, api_version='v1'):
+def _validate_username_doesnt_exist(username):
"""Validate that the username is not associated with an existing user.
:param username: The proposed username (unicode).
- :param api_version: Validation API version; it is used to determine the error message
:return: None
:raises: errors.AccountUsernameAlreadyExists
"""
- if api_version == 'v1':
- error_message = accounts.USERNAME_CONFLICT_MSG.format(username=username)
- else:
- error_message = accounts.AUTHN_USERNAME_CONFLICT_MSG
if username is not None and username_exists_or_retired(username):
- raise errors.AccountUsernameAlreadyExists(_(error_message)) # lint-amnesty, pylint: disable=translation-of-non-string
+ # lint-amnesty, pylint: disable=translation-of-non-string
+ raise errors.AccountUsernameAlreadyExists(_(accounts.AUTHN_USERNAME_CONFLICT_MSG))
-def _validate_email_doesnt_exist(email, api_version='v1'):
+def _validate_email_doesnt_exist(email):
"""Validate that the email is not associated with an existing user.
:param email: The proposed email (unicode).
- :param api_version: Validation API version; it is used to determine the error message
:return: None
:raises: errors.AccountEmailAlreadyExists
"""
- if api_version == 'v1':
- error_message = accounts.EMAIL_CONFLICT_MSG.format(email_address=email)
- else:
- error_message = accounts.AUTHN_EMAIL_CONFLICT_MSG
+ error_message = accounts.AUTHN_EMAIL_CONFLICT_MSG
if email is not None and email_exists_or_retired(email):
raise errors.AccountEmailAlreadyExists(_(error_message)) # lint-amnesty, pylint: disable=translation-of-non-string
diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py
index 1dc96f1a8e3b..7c39a751b011 100644
--- a/openedx/core/djangoapps/user_authn/views/register.py
+++ b/openedx/core/djangoapps/user_authn/views/register.py
@@ -21,6 +21,7 @@
from django.utils.translation import gettext as _
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.views.decorators.debug import sensitive_post_parameters
+from django_countries import countries
from edx_django_utils.monitoring import set_custom_attribute
from edx_toggles.toggles import WaffleFlag
from openedx_events.learning.data import UserData, UserPersonalData
@@ -588,6 +589,10 @@ def post(self, request):
if response:
return response
+ response = self._handle_country_code_validation(request, data)
+ if response:
+ return response
+
response, user = self._create_account(request, data)
if response:
return response
@@ -607,6 +612,27 @@ def post(self, request):
mark_user_change_as_expected(user.id)
return response
+ def _handle_country_code_validation(self, request, data):
+ # pylint: disable=no-member
+ country = data.get('country')
+ is_valid_country_code = country in dict(countries).keys()
+
+ errors = {}
+ error_code = 'invalid-country'
+ error_message = accounts_settings.REQUIRED_FIELD_COUNTRY_MSG
+ extra_fields = configuration_helpers.get_value(
+ 'REGISTRATION_EXTRA_FIELDS',
+ getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
+ )
+
+ if extra_fields.get('country', 'hidden') == 'required' and not is_valid_country_code:
+ errors['country'] = [{'user_message': error_message}]
+ elif country and not is_valid_country_code:
+ errors['country'] = [{'user_message': error_message}]
+
+ if errors:
+ return self._create_response(request, errors, status_code=400, error_code=error_code)
+
def _handle_duplicate_email_username(self, request, data):
# pylint: disable=no-member
# TODO Verify whether this check is needed here - it may be duplicated in user_api.
@@ -614,22 +640,15 @@ def _handle_duplicate_email_username(self, request, data):
username = data.get('username')
errors = {}
- # TODO: remove the is_authn_mfe check and use the new error message as default after redesign
- is_authn_mfe = data.get('is_authn_mfe', False)
-
error_code = 'duplicate'
if email is not None and email_exists_or_retired(email):
error_code += '-email'
- error_message = accounts_settings.AUTHN_EMAIL_CONFLICT_MSG if is_authn_mfe else (
- accounts_settings.EMAIL_CONFLICT_MSG.format(email_address=email)
- )
+ error_message = accounts_settings.AUTHN_EMAIL_CONFLICT_MSG
errors['email'] = [{'user_message': error_message}]
if username is not None and username_exists_or_retired(username):
error_code += '-username'
- error_message = accounts_settings.AUTHN_USERNAME_CONFLICT_MSG if is_authn_mfe else (
- accounts_settings.USERNAME_CONFLICT_MSG.format(username=username)
- )
+ error_message = accounts_settings.AUTHN_USERNAME_CONFLICT_MSG
errors['username'] = [{'user_message': error_message}]
errors['username_suggestions'] = generate_username_suggestions(username)
@@ -796,7 +815,6 @@ class RegistrationValidationView(APIView):
# This end-point is available to anonymous users, so no authentication is needed.
authentication_classes = []
username_suggestions = []
- api_version = 'v1'
def name_handler(self, request):
""" Validates whether fullname is valid """
@@ -811,7 +829,7 @@ def username_handler(self, request):
""" Validates whether the username is valid. """
username = request.data.get('username')
invalid_username_error = get_username_validation_error(username)
- username_exists_error = get_username_existence_validation_error(username, self.api_version)
+ username_exists_error = get_username_existence_validation_error(username)
if username_exists_error:
self.username_suggestions = generate_username_suggestions(username)
# We prefer seeing for invalidity first.
@@ -821,8 +839,8 @@ def username_handler(self, request):
def email_handler(self, request):
""" Validates whether the email address is valid. """
email = request.data.get('email')
- invalid_email_error = get_email_validation_error(email, self.api_version)
- email_exists_error = get_email_existence_validation_error(email, self.api_version)
+ invalid_email_error = get_email_validation_error(email)
+ email_exists_error = get_email_existence_validation_error(email)
# We prefer seeing for invalidity first.
# Some invalid emails (like a blank one for superusers) may exist.
return invalid_email_error or email_exists_error
@@ -878,9 +896,6 @@ def post(self, request):
can get extra verification checks if entered along with others,
like when the password may not equal the username.
"""
- # TODO: remove is_authn_mfe after redesign-master is merged in frontend-app-authn
- # and use the new messages as default
- is_auth_mfe = request.data.get('is_authn_mfe')
field_key = request.data.get('form_field_key')
validation_decisions = {}
@@ -891,9 +906,6 @@ def update_validations(field_name):
validation = self.validation_handlers[field_name](self, request)
validation_decisions[field_name] = validation
- if is_auth_mfe:
- self.api_version = 'v2'
-
if field_key and field_key in self.validation_handlers:
update_validations(field_key)
else:
diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py
index 47fcd0d324a1..cd5000ed59d9 100644
--- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py
+++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py
@@ -25,16 +25,15 @@
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
from openedx.core.djangoapps.user_api.accounts import (
AUTHN_EMAIL_CONFLICT_MSG,
+ AUTHN_EMAIL_INVALID_MSG,
AUTHN_USERNAME_CONFLICT_MSG,
EMAIL_BAD_LENGTH_MSG,
- EMAIL_CONFLICT_MSG,
- EMAIL_INVALID_MSG,
EMAIL_MAX_LENGTH,
EMAIL_MIN_LENGTH,
NAME_MAX_LENGTH,
REQUIRED_FIELD_CONFIRM_EMAIL_MSG,
+ REQUIRED_FIELD_COUNTRY_MSG,
USERNAME_BAD_LENGTH_MSG,
- USERNAME_CONFLICT_MSG,
USERNAME_INVALID_CHARS_ASCII,
USERNAME_INVALID_CHARS_UNICODE,
USERNAME_MAX_LENGTH,
@@ -91,7 +90,7 @@ class RegistrationViewValidationErrorTest(
YEAR_OF_BIRTH = "1998"
ADDRESS = "123 Fake Street"
CITY = "Springfield"
- COUNTRY = "us"
+ COUNTRY = "US"
GOALS = "Learn all the things!"
@classmethod
@@ -158,12 +157,7 @@ def test_register_retired_email_validation_error(self):
response_json,
{
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- self.EMAIL
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email"
}
@@ -205,12 +199,7 @@ def test_register_duplicate_retired_username_account_validation_error(self):
response_json,
{
"username": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different username."
- ).format(
- self.USERNAME
- )
+ "user_message": AUTHN_USERNAME_CONFLICT_MSG,
}],
"error_code": "duplicate-username"
}
@@ -243,12 +232,7 @@ def test_register_duplicate_email_validation_error(self):
response_json,
{
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- self.EMAIL
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email"
}
@@ -285,12 +269,7 @@ def test_register_duplicate_email_validation_error_with_recovery(self):
response_json,
{
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- account_recovery.secondary_email
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email"
}
@@ -344,12 +323,7 @@ def test_registration_failure_logging(self):
response_json,
{
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- self.EMAIL
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email"
}
@@ -383,12 +357,7 @@ def test_register_duplicate_username_account_validation_error(self):
response_json,
{
"username": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different username."
- ).format(
- self.USERNAME
- )
+ "user_message": AUTHN_USERNAME_CONFLICT_MSG,
}],
"error_code": "duplicate-username"
}
@@ -422,26 +391,16 @@ def test_register_duplicate_username_and_email_validation_errors(self):
response_json,
{
"username": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different username."
- ).format(
- self.USERNAME
- )
+ "user_message": AUTHN_USERNAME_CONFLICT_MSG,
}],
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- self.EMAIL
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email-username"
}
)
- def test_duplicate_email_username_error_with_is_authn_check(self):
+ def test_duplicate_email_username_error(self):
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
@@ -454,11 +413,11 @@ def test_duplicate_email_username_error_with_is_authn_check(self):
# Try to create a second user with the same username and email
response = self.client.post(self.url, {
- "is_authn_mfe": True,
"email": self.EMAIL,
"name": "Someone Else",
"username": self.USERNAME,
"password": self.PASSWORD,
+ "country": self.COUNTRY,
"honor_code": "true",
})
@@ -473,12 +432,34 @@ def test_duplicate_email_username_error_with_is_authn_check(self):
"user_message": AUTHN_USERNAME_CONFLICT_MSG,
}],
"email": [{
- "user_message": AUTHN_EMAIL_CONFLICT_MSG
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email-username"
}
)
+ def test_invalid_country_code_error(self):
+ response = self.client.post(self.url, {
+ "email": self.EMAIL,
+ "name": self.NAME,
+ "username": self.USERNAME,
+ "password": self.PASSWORD,
+ "country": "Invalid country code",
+ "honor_code": "true",
+ })
+
+ response_json = json.loads(response.content.decode('utf-8'))
+ self.assertHttpBadRequest(response)
+ self.assertDictEqual(
+ response_json,
+ {
+ "country": [{
+ "user_message": REQUIRED_FIELD_COUNTRY_MSG,
+ }],
+ "error_code": "invalid-country"
+ }
+ )
+
@ddt.ddt
@skip_unless_lms
@@ -1608,7 +1589,6 @@ def test_register_invalid_input(self, invalid_fields):
response = self.client.post(self.url, data)
self.assertHttpBadRequest(response)
- @override_settings(REGISTRATION_EXTRA_FIELDS={"country": "required"})
@ddt.data("email", "name", "username", "password", "country")
def test_register_missing_required_field(self, missing_field):
data = {
@@ -1625,6 +1605,55 @@ def test_register_missing_required_field(self, missing_field):
response = self.client.post(self.url, data)
self.assertHttpBadRequest(response)
+ @override_settings(REGISTRATION_EXTRA_FIELDS={"country": "required"})
+ def test_register_missing_country_required_field(self):
+ data = {
+ "email": self.EMAIL,
+ "name": self.NAME,
+ "username": self.USERNAME,
+ "password": self.PASSWORD,
+ "country": self.COUNTRY,
+ }
+ del data['country']
+
+ response = self.client.post(self.url, data)
+ response_json = json.loads(response.content.decode('utf-8'))
+
+ self.assertHttpBadRequest(response)
+ self.assertDictEqual(
+ response_json,
+ {
+ "country": [{
+ "user_message": REQUIRED_FIELD_COUNTRY_MSG,
+ }],
+ "error_code": "invalid-country"
+ }
+ )
+
+ @override_settings(REGISTRATION_EXTRA_FIELDS={"country": "required"})
+ def test_register_invalid_country_required_field(self):
+ data = {
+ "email": self.EMAIL,
+ "name": self.NAME,
+ "username": self.USERNAME,
+ "password": self.PASSWORD,
+ "country": "Invalid country code",
+ }
+
+ response = self.client.post(self.url, data)
+ response_json = json.loads(response.content.decode('utf-8'))
+
+ self.assertHttpBadRequest(response)
+ self.assertDictEqual(
+ response_json,
+ {
+ "country": [{
+ "user_message": REQUIRED_FIELD_COUNTRY_MSG,
+ }],
+ "error_code": "invalid-country"
+ }
+ )
+
def test_register_duplicate_email(self):
# Register the first user
response = self.client.post(self.url, {
@@ -1651,12 +1680,7 @@ def test_register_duplicate_email(self):
response_json,
{
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- self.EMAIL
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email"
}
@@ -1690,12 +1714,7 @@ def test_register_duplicate_username(self):
response_json,
{
"username": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different username."
- ).format(
- self.USERNAME
- )
+ "user_message": AUTHN_USERNAME_CONFLICT_MSG,
}],
"error_code": "duplicate-username"
}
@@ -1729,20 +1748,10 @@ def test_register_duplicate_username_and_email(self):
response_json,
{
"username": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different username."
- ).format(
- self.USERNAME
- )
+ "user_message": AUTHN_USERNAME_CONFLICT_MSG,
}],
"email": [{
- "user_message": (
- "It looks like {} belongs to an existing account. "
- "Try again with a different email address."
- ).format(
- self.EMAIL
- )
+ "user_message": AUTHN_EMAIL_CONFLICT_MSG,
}],
"error_code": "duplicate-email-username"
}
@@ -2371,8 +2380,12 @@ def _assert_existing_user_error(self, response):
assert response.status_code == 409
errors = json.loads(response.content.decode('utf-8'))
for conflict_attribute in ["username", "email"]:
+ if conflict_attribute == 'username':
+ error_message = AUTHN_USERNAME_CONFLICT_MSG
+ else:
+ error_message = AUTHN_EMAIL_CONFLICT_MSG
assert conflict_attribute in errors
- assert "belongs to an existing account" in errors[conflict_attribute][0]["user_message"]
+ assert error_message == errors[conflict_attribute][0]["user_message"]
def _assert_access_token_error(self, response, expected_error_message, error_code):
"""Assert that the given response was an error for the access_token field with the given error message."""
@@ -2665,13 +2678,9 @@ def test_existence_conflict(self, username, email, validate_suggestions):
},
{
# pylint: disable=no-member
- "username": USERNAME_CONFLICT_MSG.format(
- username=user.username
- ) if username == user.username else '',
+ "username": AUTHN_USERNAME_CONFLICT_MSG if username == user.username else '',
# pylint: disable=no-member
- "email": EMAIL_CONFLICT_MSG.format(
- email_address=user.email
- ) if email == user.email else ''
+ "email": AUTHN_EMAIL_CONFLICT_MSG if email == user.email else ''
},
validate_suggestions
)
@@ -2684,11 +2693,10 @@ def test_email_bad_length_validation_decision(self, email):
)
def test_email_generically_invalid_validation_decision(self):
- email = 'email'
self.assertValidationDecision(
- {'email': email},
+ {'email': 'email'},
# pylint: disable=no-member
- {'email': EMAIL_INVALID_MSG.format(email=email)}
+ {'email': AUTHN_EMAIL_INVALID_MSG}
)
def test_confirm_email_matches_email(self):
diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt
index 93ecbec6054a..97e0e4e20388 100644
--- a/requirements/common_constraints.txt
+++ b/requirements/common_constraints.txt
@@ -24,6 +24,8 @@ Django<4.0
# elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html
elasticsearch<7.14.0
+# setuptools==60.0 had breaking changes and busted several service's pipeline.
+# Details can be found here: https://github.com/pypa/setuptools/issues/2940
setuptools<60
# django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index ae5bd8ce3ac5..debc4acf3155 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -25,12 +25,12 @@ django-storages<1.9
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
-edx-enterprise==3.56.6
+edx-enterprise==3.56.9
# As of 2022-08-24, Arch-BOM (at 2U) is changing event-bus-kafka rapidly, with breaking changes.
# As long as that is happening, Arch-BOM will manually bump this dependency to ensure
# that any necessary code changes can happen in lockstep.
-edx-event-bus-kafka==0.4.3
+edx-event-bus-kafka==0.6.0
# oauthlib>3.0.1 causes test failures ( also remove the django-oauth-toolkit constraint when this is fixed )
oauthlib==3.0.1
@@ -93,3 +93,7 @@ markdown<3.4.0
# pycodestyle==2.9.0 generates false positive error E275.
# Constraint can be removed once the issue https://github.com/PyCQA/pycodestyle/issues/1090 is fixed.
pycodestyle<2.9.0
+
+# pinned 8/30/22
+# proctoring 4.11.0 breaks collect static stage of AMI building, can be removed once MST-1622 is resolved
+edx-proctoring==4.10.3
diff --git a/requirements/edx-sandbox/py38.txt b/requirements/edx-sandbox/py38.txt
index 7232a1f2ed86..363199312874 100644
--- a/requirements/edx-sandbox/py38.txt
+++ b/requirements/edx-sandbox/py38.txt
@@ -36,7 +36,7 @@ matplotlib==3.3.4
# -r requirements/edx-sandbox/py38.in
mpmath==1.2.1
# via sympy
-networkx==2.8.5
+networkx==2.8.6
# via -r requirements/edx-sandbox/py38.in
nltk==3.7
# via
@@ -64,7 +64,7 @@ python-dateutil==2.8.2
# via matplotlib
random2==1.0.1
# via -r requirements/edx-sandbox/py38.in
-regex==2022.7.25
+regex==2022.8.17
# via nltk
scipy==1.7.3
# via
@@ -77,7 +77,7 @@ six==1.16.0
# chem
# codejail-includes
# python-dateutil
-sympy==1.10.1
+sympy==1.11
# via
# -r requirements/edx-sandbox/py38.in
# openedx-calc
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index a5ce8d1eaf5a..1ed7f3323144 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -163,9 +163,9 @@ cryptography==36.0.2
# pyopenssl
# snowflake-connector-python
# social-auth-core
-cssutils==2.5.1
+cssutils==2.6.0
# via pynliner
-ddt==1.5.0
+ddt==1.6.0
# via
# xblock-drag-and-drop-v2
# xblock-poll
@@ -328,7 +328,7 @@ django-mptt==0.13.4
# django-wiki
django-multi-email-field==0.6.2
# via edx-enterprise
-django-mysql==4.7.0
+django-mysql==4.7.1
# via -r requirements/edx/base.in
django-oauth-toolkit==1.3.2
# via
@@ -348,7 +348,7 @@ django-sekizai==4.0.0
# via
# -r requirements/edx/base.in
# django-wiki
-django-ses==3.1.0
+django-ses==3.1.2
# via -r requirements/edx/base.in
django-simple-history==3.0.0
# via
@@ -362,7 +362,7 @@ django-simple-history==3.0.0
# ora2
django-splash==1.2.1
# via -r requirements/edx/base.in
-django-statici18n==2.2.0
+django-statici18n==2.3.1
# via -r requirements/edx/base.in
django-storages==1.8
# via
@@ -371,7 +371,7 @@ django-storages==1.8
# edxval
django-user-tasks==3.0.0
# via -r requirements/edx/base.in
-django-waffle==2.6.0
+django-waffle==2.7.0
# via
# -r requirements/edx/base.in
# blockstore
@@ -470,7 +470,7 @@ edx-django-utils==5.0.0
# ora2
# outcome-surveys
# super-csv
-edx-drf-extensions==8.0.1
+edx-drf-extensions==8.2.0
# via
# -r requirements/edx/base.in
# edx-completion
@@ -482,12 +482,12 @@ edx-drf-extensions==8.0.1
# edx-when
# edxval
# learner-pathway-progress
-edx-enterprise==3.56.6
+edx-enterprise==3.56.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.in
# learner-pathway-progress
-edx-event-bus-kafka==0.4.3
+edx-event-bus-kafka==0.6.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.in
@@ -520,6 +520,7 @@ edx-organizations==6.11.1
# via -r requirements/edx/base.in
edx-proctoring==4.10.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.in
# edx-proctoring-proctortrack
edx-proctoring-proctortrack==1.0.5
@@ -573,9 +574,11 @@ event-tracking==2.1.0
# -r requirements/edx/base.in
# edx-proctoring
# edx-search
-fastavro==1.5.4
+fastavro==1.6.0
# via openedx-events
-frozenlist==1.3.0
+filelock==3.8.0
+ # via snowflake-connector-python
+frozenlist==1.3.1
# via
# aiohttp
# aiosignal
@@ -665,7 +668,7 @@ lazy==1.4
# acid-xblock
# lti-consumer-xblock
# ora2
-learner-pathway-progress==1.3.0
+learner-pathway-progress==1.3.1
# via -r requirements/edx/base.in
libsass==0.10.0
# via
@@ -687,7 +690,7 @@ lxml==4.9.1
# xmlsec
mailsnake==1.6.4
# via -r requirements/edx/base.in
-mako==1.2.1
+mako==1.2.2
# via
# -r requirements/edx/base.in
# acid-xblock
@@ -736,7 +739,7 @@ mysqlclient==2.1.1
# via
# -r requirements/edx/base.in
# blockstore
-newrelic==7.16.0.178
+newrelic==8.0.0.179
# via
# -r requirements/edx/base.in
# edx-django-utils
@@ -797,7 +800,7 @@ path-py==12.5.0
# staff-graded-xblock
paver==1.3.4
# via -r requirements/edx/paver.txt
-pbr==5.9.0
+pbr==5.10.0
# via
# -r requirements/edx/paver.txt
# stevedore
@@ -835,7 +838,7 @@ pycryptodomex==3.15.0
# lti-consumer-xblock
# pyjwkest
# snowflake-connector-python
-pygments==2.12.0
+pygments==2.13.0
# via
# -r requirements/edx/base.in
# py2neo
@@ -918,7 +921,7 @@ python3-saml==1.9.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.in
-pytz==2022.1
+pytz==2022.2.1
# via
# -r requirements/edx/base.in
# babel
@@ -956,7 +959,7 @@ recommender-xblock==2.0.1
# via -r requirements/edx/base.in
redis==4.3.4
# via -r requirements/edx/base.in
-regex==2022.7.25
+regex==2022.8.17
# via nltk
requests==2.28.1
# via
@@ -1005,7 +1008,7 @@ scipy==1.7.3
# openedx-calc
semantic-version==2.10.0
# via edx-drf-extensions
-shapely==1.8.2
+shapely==1.8.4
# via -r requirements/edx/base.in
simplejson==3.17.6
# via
@@ -1050,7 +1053,7 @@ slumber==0.7.1
# edx-bulk-grades
# edx-enterprise
# edx-rest-api-client
-snowflake-connector-python==2.7.11
+snowflake-connector-python==2.7.12
# via edx-enterprise
social-auth-app-django==5.0.0
# via
@@ -1085,11 +1088,11 @@ stevedore==4.0.0
# edx-django-utils
# edx-enterprise
# edx-opaque-keys
-super-csv==3.0.0
+super-csv==3.0.1
# via
# -r requirements/edx/base.in
# edx-bulk-grades
-sympy==1.10.1
+sympy==1.11
# via openedx-calc
tableauserverclient==0.19.0
# via edx-enterprise
@@ -1113,7 +1116,7 @@ uritemplate==4.1.1
# via
# coreapi
# drf-yasg
-urllib3==1.26.11
+urllib3==1.26.12
# via
# -r requirements/edx/paver.txt
# elasticsearch
@@ -1185,7 +1188,7 @@ xblock-utils==3.0.0
# staff-graded-xblock
# xblock-drag-and-drop-v2
# xblock-google-drive
-xmlsec==1.3.12
+xmlsec==1.3.13
# via python3-saml
xss-utils==0.4.0
# via -r requirements/edx/base.in
diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt
index 3dac341bfd6b..e4dd73dbd6db 100644
--- a/requirements/edx/coverage.txt
+++ b/requirements/edx/coverage.txt
@@ -6,7 +6,7 @@
#
chardet==5.0.0
# via diff-cover
-coverage==6.4.2
+coverage==6.4.4
# via -r requirements/edx/coverage.in
diff-cover==6.5.1
# via -r requirements/edx/coverage.in
@@ -16,5 +16,5 @@ markupsafe==2.1.1
# via jinja2
pluggy==1.0.0
# via diff-cover
-pygments==2.12.0
+pygments==2.13.0
# via diff-cover
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index ca2f69d51cda..e624b77f27cd 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -216,7 +216,7 @@ coreschema==0.0.4
# -r requirements/edx/testing.txt
# coreapi
# drf-yasg
-coverage[toml]==6.4.2
+coverage[toml]==6.4.4
# via
# -r requirements/edx/testing.txt
# pytest-cov
@@ -237,11 +237,11 @@ cssselect==1.1.0
# via
# -r requirements/edx/testing.txt
# pyquery
-cssutils==2.5.1
+cssutils==2.6.0
# via
# -r requirements/edx/testing.txt
# pynliner
-ddt==1.5.0
+ddt==1.6.0
# via
# -r requirements/edx/testing.txt
# xblock-drag-and-drop-v2
@@ -266,7 +266,7 @@ dill==0.3.5.1
# via
# -r requirements/edx/testing.txt
# pylint
-distlib==0.3.5
+distlib==0.3.6
# via
# -r requirements/edx/testing.txt
# virtualenv
@@ -372,7 +372,7 @@ django-crum==0.7.9
# edx-rbac
# edx-toggles
# super-csv
-django-debug-toolbar==3.5.0
+django-debug-toolbar==3.6.0
# via -r requirements/edx/development.in
django-environ==0.9.0
# via
@@ -429,7 +429,7 @@ django-multi-email-field==0.6.2
# via
# -r requirements/edx/testing.txt
# edx-enterprise
-django-mysql==4.7.0
+django-mysql==4.7.1
# via -r requirements/edx/testing.txt
django-oauth-toolkit==1.3.2
# via
@@ -451,7 +451,7 @@ django-sekizai==4.0.0
# via
# -r requirements/edx/testing.txt
# django-wiki
-django-ses==3.1.0
+django-ses==3.1.2
# via -r requirements/edx/testing.txt
django-simple-history==3.0.0
# via
@@ -465,7 +465,7 @@ django-simple-history==3.0.0
# ora2
django-splash==1.2.1
# via -r requirements/edx/testing.txt
-django-statici18n==2.2.0
+django-statici18n==2.3.1
# via -r requirements/edx/testing.txt
django-storages==1.8
# via
@@ -474,7 +474,7 @@ django-storages==1.8
# edxval
django-user-tasks==3.0.0
# via -r requirements/edx/testing.txt
-django-waffle==2.6.0
+django-waffle==2.7.0
# via
# -r requirements/edx/testing.txt
# blockstore
@@ -584,7 +584,7 @@ edx-django-utils==5.0.0
# ora2
# outcome-surveys
# super-csv
-edx-drf-extensions==8.0.1
+edx-drf-extensions==8.2.0
# via
# -r requirements/edx/testing.txt
# edx-completion
@@ -596,12 +596,12 @@ edx-drf-extensions==8.0.1
# edx-when
# edxval
# learner-pathway-progress
-edx-enterprise==3.56.6
+edx-enterprise==3.56.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/testing.txt
# learner-pathway-progress
-edx-event-bus-kafka==0.4.3
+edx-event-bus-kafka==0.6.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/testing.txt
@@ -637,6 +637,7 @@ edx-organizations==6.11.1
# via -r requirements/edx/testing.txt
edx-proctoring==4.10.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/testing.txt
# edx-proctoring-proctortrack
edx-proctoring-proctortrack==1.0.5
@@ -705,26 +706,27 @@ execnet==1.9.0
# pytest-xdist
factory-boy==3.2.1
# via -r requirements/edx/testing.txt
-faker==13.15.1
+faker==14.1.0
# via
# -r requirements/edx/testing.txt
# factory-boy
-fastapi==0.79.0
+fastapi==0.81.0
# via
# -r requirements/edx/testing.txt
# pact-python
-fastavro==1.5.4
+fastavro==1.6.0
# via
# -r requirements/edx/testing.txt
# openedx-events
-filelock==3.7.1
+filelock==3.8.0
# via
# -r requirements/edx/testing.txt
+ # snowflake-connector-python
# tox
# virtualenv
-freezegun==1.2.1
+freezegun==1.2.2
# via -r requirements/edx/testing.txt
-frozenlist==1.3.0
+frozenlist==1.3.1
# via
# -r requirements/edx/testing.txt
# aiohttp
@@ -867,7 +869,7 @@ lazy-object-proxy==1.7.1
# via
# -r requirements/edx/testing.txt
# astroid
-learner-pathway-progress==1.3.0
+learner-pathway-progress==1.3.1
# via -r requirements/edx/testing.txt
libsass==0.10.0
# via
@@ -894,7 +896,7 @@ m2r==0.2.1
# via sphinxcontrib-openapi
mailsnake==1.6.4
# via -r requirements/edx/testing.txt
-mako==1.2.1
+mako==1.2.2
# via
# -r requirements/edx/testing.txt
# acid-xblock
@@ -963,7 +965,7 @@ mysqlclient==2.1.1
# via
# -r requirements/edx/testing.txt
# blockstore
-newrelic==7.16.0.178
+newrelic==8.0.0.179
# via
# -r requirements/edx/testing.txt
# edx-django-utils
@@ -1037,7 +1039,7 @@ path-py==12.5.0
# staff-graded-xblock
paver==1.3.4
# via -r requirements/edx/testing.txt
-pbr==5.9.0
+pbr==5.10.0
# via
# -r requirements/edx/testing.txt
# stevedore
@@ -1114,11 +1116,11 @@ pycryptodomex==3.15.0
# lti-consumer-xblock
# pyjwkest
# snowflake-connector-python
-pydantic==1.9.1
+pydantic==1.9.2
# via
# -r requirements/edx/testing.txt
# fastapi
-pygments==2.12.0
+pygments==2.13.0
# via
# -r requirements/edx/testing.txt
# diff-cover
@@ -1274,7 +1276,7 @@ python3-saml==1.9.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/testing.txt
-pytz==2022.1
+pytz==2022.2.1
# via
# -r requirements/edx/testing.txt
# babel
@@ -1315,7 +1317,7 @@ recommender-xblock==2.0.1
# via -r requirements/edx/testing.txt
redis==4.3.4
# via -r requirements/edx/testing.txt
-regex==2022.7.25
+regex==2022.8.17
# via
# -r requirements/edx/testing.txt
# nltk
@@ -1383,7 +1385,7 @@ semantic-version==2.10.0
# via
# -r requirements/edx/testing.txt
# edx-drf-extensions
-shapely==1.8.2
+shapely==1.8.4
# via -r requirements/edx/testing.txt
simplejson==3.17.6
# via
@@ -1443,7 +1445,7 @@ sniffio==1.2.0
# anyio
snowballstemmer==2.2.0
# via sphinx
-snowflake-connector-python==2.7.11
+snowflake-connector-python==2.7.12
# via
# -r requirements/edx/testing.txt
# edx-enterprise
@@ -1507,11 +1509,11 @@ stevedore==4.0.0
# edx-django-utils
# edx-enterprise
# edx-opaque-keys
-super-csv==3.0.0
+super-csv==3.0.1
# via
# -r requirements/edx/testing.txt
# edx-bulk-grades
-sympy==1.10.1
+sympy==1.11
# via
# -r requirements/edx/testing.txt
# openedx-calc
@@ -1577,7 +1579,7 @@ uritemplate==4.1.1
# -r requirements/edx/testing.txt
# coreapi
# drf-yasg
-urllib3==1.26.11
+urllib3==1.26.12
# via
# -r requirements/edx/testing.txt
# elasticsearch
@@ -1589,7 +1591,7 @@ urllib3==1.26.11
# snowflake-connector-python
user-util==1.0.0
# via -r requirements/edx/testing.txt
-uvicorn==0.18.2
+uvicorn==0.18.3
# via
# -r requirements/edx/testing.txt
# pact-python
@@ -1599,7 +1601,7 @@ vine==5.0.0
# amqp
# celery
# kombu
-virtualenv==20.16.2
+virtualenv==20.16.3
# via
# -r requirements/edx/testing.txt
# tox
@@ -1672,7 +1674,7 @@ xblock-utils==3.0.0
# staff-graded-xblock
# xblock-drag-and-drop-v2
# xblock-google-drive
-xmlsec==1.3.12
+xmlsec==1.3.13
# via
# -r requirements/edx/testing.txt
# python3-saml
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index d0bfa2222736..8828c532a6dc 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -44,15 +44,15 @@ markupsafe==2.1.1
# via jinja2
packaging==21.3
# via sphinx
-pbr==5.9.0
+pbr==5.10.0
# via stevedore
-pygments==2.12.0
+pygments==2.13.0
# via sphinx
pyparsing==3.0.9
# via packaging
python-slugify==6.1.2
# via code-annotations
-pytz==2022.1
+pytz==2022.2.1
# via babel
pyyaml==6.0
# via code-annotations
@@ -85,7 +85,7 @@ stevedore==4.0.0
# via code-annotations
text-unidecode==1.3
# via python-slugify
-urllib3==1.26.11
+urllib3==1.26.12
# via requests
zipp==3.8.1
# via importlib-metadata
diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt
index 7f6e53d8a111..7757dc98cc4d 100644
--- a/requirements/edx/paver.txt
+++ b/requirements/edx/paver.txt
@@ -26,7 +26,7 @@ path==16.4.0
# via -r requirements/edx/paver.in
paver==1.3.4
# via -r requirements/edx/paver.in
-pbr==5.9.0
+pbr==5.10.0
# via stevedore
psutil==5.9.1
# via -r requirements/edx/paver.in
@@ -48,7 +48,7 @@ stevedore==4.0.0
# via
# -r requirements/edx/paver.in
# edx-opaque-keys
-urllib3==1.26.11
+urllib3==1.26.12
# via requests
watchdog==2.1.9
# via -r requirements/edx/paver.in
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 735cdb0e49cf..75c52c489874 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -70,7 +70,6 @@ attrs==22.1.0
# edx-ace
# jsonschema
# openedx-events
- # outcome
# pytest
babel==2.10.3
# via
@@ -205,7 +204,7 @@ coreschema==0.0.4
# -r requirements/edx/base.txt
# coreapi
# drf-yasg
-coverage[toml]==6.4.2
+coverage[toml]==6.4.4
# via
# -r requirements/edx/coverage.txt
# pytest-cov
@@ -226,11 +225,11 @@ cssselect==1.1.0
# via
# -r requirements/edx/testing.in
# pyquery
-cssutils==2.5.1
+cssutils==2.6.0
# via
# -r requirements/edx/base.txt
# pynliner
-ddt==1.5.0
+ddt==1.6.0
# via
# -r requirements/edx/base.txt
# -r requirements/edx/testing.in
@@ -254,7 +253,7 @@ diff-cover==6.5.1
# via -r requirements/edx/coverage.txt
dill==0.3.5.1
# via pylint
-distlib==0.3.5
+distlib==0.3.6
# via virtualenv
# via
# -c requirements/edx/../common_constraints.txt
@@ -411,7 +410,7 @@ django-multi-email-field==0.6.2
# via
# -r requirements/edx/base.txt
# edx-enterprise
-django-mysql==4.7.0
+django-mysql==4.7.1
# via -r requirements/edx/base.txt
django-oauth-toolkit==1.3.2
# via
@@ -433,7 +432,7 @@ django-sekizai==4.0.0
# via
# -r requirements/edx/base.txt
# django-wiki
-django-ses==3.1.0
+django-ses==3.1.2
# via -r requirements/edx/base.txt
django-simple-history==3.0.0
# via
@@ -447,7 +446,7 @@ django-simple-history==3.0.0
# ora2
django-splash==1.2.1
# via -r requirements/edx/base.txt
-django-statici18n==2.2.0
+django-statici18n==2.3.1
# via -r requirements/edx/base.txt
django-storages==1.8
# via
@@ -456,7 +455,7 @@ django-storages==1.8
# edxval
django-user-tasks==3.0.0
# via -r requirements/edx/base.txt
-django-waffle==2.6.0
+django-waffle==2.7.0
# via
# -r requirements/edx/base.txt
# blockstore
@@ -564,7 +563,7 @@ edx-django-utils==5.0.0
# ora2
# outcome-surveys
# super-csv
-edx-drf-extensions==8.0.1
+edx-drf-extensions==8.2.0
# via
# -r requirements/edx/base.txt
# edx-completion
@@ -576,12 +575,12 @@ edx-drf-extensions==8.0.1
# edx-when
# edxval
# learner-pathway-progress
-edx-enterprise==3.56.6
+edx-enterprise==3.56.9
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# learner-pathway-progress
-edx-event-bus-kafka==0.4.3
+edx-event-bus-kafka==0.6.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
@@ -618,6 +617,7 @@ edx-organizations==6.11.1
# via -r requirements/edx/base.txt
edx-proctoring==4.10.3
# via
+ # -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
# edx-proctoring-proctortrack
edx-proctoring-proctortrack==1.0.5
@@ -682,21 +682,23 @@ execnet==1.9.0
# via pytest-xdist
factory-boy==3.2.1
# via -r requirements/edx/testing.in
-faker==13.15.1
+faker==14.1.0
# via factory-boy
-fastapi==0.79.0
+fastapi==0.81.0
# via pact-python
-fastavro==1.5.4
+fastavro==1.6.0
# via
# -r requirements/edx/base.txt
# openedx-events
-filelock==3.7.1
+filelock==3.8.0
# via
+ # -r requirements/edx/base.txt
+ # snowflake-connector-python
# tox
# virtualenv
-freezegun==1.2.1
+freezegun==1.2.2
# via -r requirements/edx/testing.in
-frozenlist==1.3.0
+frozenlist==1.3.1
# via
# -r requirements/edx/base.txt
# aiohttp
@@ -829,7 +831,7 @@ lazy==1.4
# ora2
lazy-object-proxy==1.7.1
# via astroid
-learner-pathway-progress==1.3.0
+learner-pathway-progress==1.3.1
# via -r requirements/edx/base.txt
libsass==0.10.0
# via
@@ -854,7 +856,7 @@ lxml==4.9.1
# xmlsec
mailsnake==1.6.4
# via -r requirements/edx/base.txt
-mako==1.2.1
+mako==1.2.2
# via
# -r requirements/edx/base.txt
# acid-xblock
@@ -914,7 +916,7 @@ mysqlclient==2.1.1
# via
# -r requirements/edx/base.txt
# blockstore
-newrelic==7.16.0.178
+newrelic==8.0.0.179
# via
# -r requirements/edx/base.txt
# edx-django-utils
@@ -985,7 +987,7 @@ path-py==12.5.0
# staff-graded-xblock
paver==1.3.4
# via -r requirements/edx/base.txt
-pbr==5.9.0
+pbr==5.10.0
# via
# -r requirements/edx/base.txt
# stevedore
@@ -1055,9 +1057,9 @@ pycryptodomex==3.15.0
# lti-consumer-xblock
# pyjwkest
# snowflake-connector-python
-pydantic==1.9.1
+pydantic==1.9.2
# via fastapi
-pygments==2.12.0
+pygments==2.13.0
# via
# -r requirements/edx/base.txt
# -r requirements/edx/coverage.txt
@@ -1204,7 +1206,7 @@ python3-saml==1.9.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-pytz==2022.1
+pytz==2022.2.1
# via
# -r requirements/edx/base.txt
# babel
@@ -1242,7 +1244,7 @@ recommender-xblock==2.0.1
# via -r requirements/edx/base.txt
redis==4.3.4
# via -r requirements/edx/base.txt
-regex==2022.7.25
+regex==2022.8.17
# via
# -r requirements/edx/base.txt
# nltk
@@ -1309,7 +1311,7 @@ semantic-version==2.10.0
# via
# -r requirements/edx/base.txt
# edx-drf-extensions
-shapely==1.8.2
+shapely==1.8.4
# via -r requirements/edx/base.txt
simplejson==3.17.6
# via
@@ -1363,7 +1365,7 @@ slumber==0.7.1
# edx-rest-api-client
sniffio==1.2.0
# via anyio
-snowflake-connector-python==2.7.11
+snowflake-connector-python==2.7.12
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1403,11 +1405,11 @@ stevedore==4.0.0
# edx-django-utils
# edx-enterprise
# edx-opaque-keys
-super-csv==3.0.0
+super-csv==3.0.1
# via
# -r requirements/edx/base.txt
# edx-bulk-grades
-sympy==1.10.1
+sympy==1.11
# via
# -r requirements/edx/base.txt
# openedx-calc
@@ -1465,7 +1467,7 @@ uritemplate==4.1.1
# -r requirements/edx/base.txt
# coreapi
# drf-yasg
-urllib3==1.26.11
+urllib3==1.26.12
# via
# -r requirements/edx/base.txt
# elasticsearch
@@ -1477,7 +1479,7 @@ urllib3==1.26.11
# snowflake-connector-python
user-util==1.0.0
# via -r requirements/edx/base.txt
-uvicorn==0.18.2
+uvicorn==0.18.3
# via pact-python
vine==5.0.0
# via
@@ -1485,7 +1487,7 @@ vine==5.0.0
# amqp
# celery
# kombu
-virtualenv==20.16.2
+virtualenv==20.16.3
# via tox
voluptuous==0.13.1
# via
@@ -1550,7 +1552,7 @@ xblock-utils==3.0.0
# staff-graded-xblock
# xblock-drag-and-drop-v2
# xblock-google-drive
-xmlsec==1.3.12
+xmlsec==1.3.13
# via
# -r requirements/edx/base.txt
# python3-saml
diff --git a/scripts/xblock/requirements.txt b/scripts/xblock/requirements.txt
index fa7f3f6dc9d6..bb355d0eca2c 100644
--- a/scripts/xblock/requirements.txt
+++ b/scripts/xblock/requirements.txt
@@ -6,11 +6,11 @@
#
certifi==2022.6.15
# via requests
-charset-normalizer==2.1.0
+charset-normalizer==2.1.1
# via requests
idna==3.3
# via requests
requests==2.28.1
# via -r scripts/xblock/requirements.in
-urllib3==1.26.11
+urllib3==1.26.12
# via requests
diff --git a/xmodule/discussion_block.py b/xmodule/discussion_block.py
index 25a6be2138e1..c7eb09d2a278 100644
--- a/xmodule/discussion_block.py
+++ b/xmodule/discussion_block.py
@@ -15,6 +15,7 @@
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
+from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider
from openedx.core.djangolib.markup import HTML, Text
from openedx.core.lib.xblock_utils import get_css_dependencies, get_js_dependencies
from xmodule.xml_module import XmlParserMixin
@@ -81,6 +82,14 @@ def course_key(self):
"""
return getattr(self.scope_ids.usage_id, 'course_key', None)
+ @property
+ def is_visible(self):
+ """
+ Discussion Xblock does not support new OPEN_EDX provider
+ """
+ provider = DiscussionsConfiguration.get(self.course_key)
+ return provider.provider_type == Provider.LEGACY
+
@property
def django_user(self):
"""
@@ -162,8 +171,11 @@ def student_view(self, context=None):
Renders student view for LMS.
"""
fragment = Fragment()
- self.add_resource_urls(fragment)
+ if not self.is_visible:
+ return fragment
+
+ self.add_resource_urls(fragment)
login_msg = ''
if not self.django_user.is_authenticated:
@@ -210,7 +222,10 @@ def author_view(self, context=None): # pylint: disable=unused-argument
fragment = Fragment()
fragment.add_content(self.runtime.service(self, 'mako').render_template(
'discussion/_discussion_inline_studio.html',
- {'discussion_id': self.discussion_id}
+ {
+ 'discussion_id': self.discussion_id,
+ 'is_visible': self.is_visible,
+ }
))
return fragment