diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index a0415d4e06dc..d7d62172759f 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -3,10 +3,8 @@ from django.conf import settings from django.http import Http404 -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from opaque_keys.edx.keys import CourseKey from rest_framework import parsers, permissions, status, viewsets -from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action from rest_framework.response import Response @@ -21,7 +19,6 @@ class CourseRunViewSet(viewsets.GenericViewSet): # lint-amnesty, pylint: disable=missing-class-docstring - authentication_classes = (JwtAuthentication, SessionAuthentication,) lookup_value_regex = settings.COURSE_KEY_REGEX permission_classes = (permissions.IsAdminUser,) serializer_class = CourseRunSerializer diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 8b122d8c8da0..44b89aa3a5f5 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -498,7 +498,6 @@ def _save_xblock( publish = "make_public" # Make public after updating the xblock, in case the caller asked for both an update and a publish. - # Used by Bok Choy tests and by republishing of staff locks. if publish == "make_public": modulestore().publish(xblock.location, user.id) diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index 8e0d5d887eeb..263f237981ca 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -357,7 +357,7 @@ def validate_single_topic(cls, topic_settings): """ error_list = [] valid_teamset_types = [TeamsetType.open.value, TeamsetType.public_managed.value, - TeamsetType.private_managed.value] + TeamsetType.private_managed.value, TeamsetType.open_managed.value] valid_keys = {'id', 'name', 'description', 'max_team_size', 'type'} teamset_type = topic_settings.get('type', {}) if teamset_type: diff --git a/cms/djangoapps/models/settings/tests/test_settings.py b/cms/djangoapps/models/settings/tests/test_settings.py index 314220d95bdd..67afe97274f3 100644 --- a/cms/djangoapps/models/settings/tests/test_settings.py +++ b/cms/djangoapps/models/settings/tests/test_settings.py @@ -41,6 +41,12 @@ "type": "private_managed", "description": "Private Topic 2 desc", "name": "Private Topic 2 Name" + }, + { + "id": "open_managed_topic_1_id", + "type": "open_managed", + "description": "Open Managed Topic 1 desc", + "name": "Open Managed Topic 1 Name" } ] } diff --git a/cms/envs/common.py b/cms/envs/common.py index 8f974e73e521..4a6c6cc68ead 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -129,6 +129,7 @@ from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin from cms.lib.xblock.authoring_mixin import AuthoringMixin +from cms.lib.xblock.tagging.tagged_block_mixin import TaggedBlockMixin from xmodule.modulestore.edit_info import EditInfoMixin from openedx.core.djangoapps.theming.helpers_dirs import ( get_themes_unchecked, @@ -975,6 +976,7 @@ XModuleMixin, EditInfoMixin, AuthoringMixin, + TaggedBlockMixin, ) XBLOCK_EXTRA_MIXINS = () @@ -2193,7 +2195,7 @@ 'KEY_PREFIX': 'course_structure', 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', 'LOCATION': ['localhost:11211'], - 'TIMEOUT': '7200', + 'TIMEOUT': '604800', # 1 week 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', 'OPTIONS': { 'no_delay': True, diff --git a/cms/envs/devstack-experimental.yml b/cms/envs/devstack-experimental.yml index 54f6edf8f9f2..c08b19045faa 100644 --- a/cms/envs/devstack-experimental.yml +++ b/cms/envs/devstack-experimental.yml @@ -84,7 +84,7 @@ CACHES: KEY_PREFIX: course_structure LOCATION: - edx.devstack.memcached:11211 - TIMEOUT: '7200' + TIMEOUT: '604800' default: BACKEND: django.core.cache.backends.memcached.PyMemcacheCache OPTIONS: diff --git a/cms/lib/xblock/tagging/tagged_block_mixin.py b/cms/lib/xblock/tagging/tagged_block_mixin.py new file mode 100644 index 000000000000..dba1a16c8856 --- /dev/null +++ b/cms/lib/xblock/tagging/tagged_block_mixin.py @@ -0,0 +1,57 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +from urllib.parse import quote + + +class TaggedBlockMixin: + """ + Mixin containing XML serializing and parsing functionality for tagged blocks + """ + + def serialize_tag_data(self): + """ + Serialize block's tag data to include in the xml, escaping special characters + + Example tags: + LightCast Skills Taxonomy: ["Typing", "Microsoft Office"] + Open Canada Skills Taxonomy: ["MS Office", ""] + + Example serialized tags: + lightcast-skills:Typing,Microsoft Office;open-canada-skills:MS Office,%3Csome%3A%3B%2Cskill%2F%7C%3D%3E + """ + # This import is done here since we import and use TaggedBlockMixin in the cms settings, but the + # content_tagging app wouldn't have loaded yet, so importing it outside causes an error + from openedx.core.djangoapps.content_tagging.api import get_object_tags + content_tags = get_object_tags(self.scope_ids.usage_id) + + serialized_tags = [] + taxonomies_and_tags = {} + for tag in content_tags: + taxonomy_export_id = tag.taxonomy.export_id + + if not taxonomies_and_tags.get(taxonomy_export_id): + taxonomies_and_tags[taxonomy_export_id] = [] + + # Escape special characters in tag values, except spaces (%20) for better readability + escaped_tag = quote(tag.value).replace("%20", " ") + taxonomies_and_tags[taxonomy_export_id].append(escaped_tag) + + for taxonomy in taxonomies_and_tags: + merged_tags = ','.join(taxonomies_and_tags.get(taxonomy)) + serialized_tags.append(f"{taxonomy}:{merged_tags}") + + return ";".join(serialized_tags) + + def add_tags_to_node(self, node): + """ + Serialize and add tag data (if any) to node + """ + tag_data = self.serialize_tag_data() + if tag_data: + node.set('tags-v1', tag_data) + + def add_xml_to_node(self, node): + """ + Include the serialized tag data in XML when adding to node + """ + super().add_xml_to_node(node) + self.add_tags_to_node(node) diff --git a/common/djangoapps/entitlements/rest_api/v1/views.py b/common/djangoapps/entitlements/rest_api/v1/views.py index 9442dae29ccd..3306604d5d13 100644 --- a/common/djangoapps/entitlements/rest_api/v1/views.py +++ b/common/djangoapps/entitlements/rest_api/v1/views.py @@ -14,7 +14,6 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status, viewsets -from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from rest_framework.views import APIView @@ -328,7 +327,6 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): - Unenroll - Switch Enrollment """ - authentication_classes = (JwtAuthentication, SessionAuthentication,) # TODO: ARCH-91 # This view is excluded from Swagger doc generation because it # does not specify a serializer class. diff --git a/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py b/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py deleted file mode 100644 index 6c13392d224b..000000000000 --- a/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py +++ /dev/null @@ -1,139 +0,0 @@ -""" Management command to add user emails data on Braze. """ -import logging -import time - -from braze.client import BrazeClient -from braze.exceptions import BrazeClientError -from django.conf import settings -from django.contrib.auth import get_user_model -from django.core.management.base import BaseCommand - -from common.djangoapps.util.query import use_read_replica_if_available - -User = get_user_model() - -MARKETING_EMAIL_ATTRIBUTE_NAME = 'is_marketable' -TRACK_USER_COMPONENT_CHUNK_SIZE = 75 - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """ - Command to add user email address on Braze against a user_id. - Example usage: - $ ./manage.py lms populate_user_emails_on_braze - """ - help = """ - Updates user accounts with email addresses on braze. - """ - - def add_arguments(self, parser): - """ - Function to get command arguments - """ - parser.add_argument( - '--batch-delay', - type=float, - dest='batch_delay', - default=0.5, - help='Time delay in seconds between each iteration' - ) - parser.add_argument( - '--batch-size', - type=int, - dest='batch_size', - default=10000, - help='Batch size' - ) - parser.add_argument( - '--starting-user-id', - type=int, - dest='starting_user_id', - default=0, - help='Starting user id to process a specific batch of users. ' - 'Both start and end id should be provided.', - ) - parser.add_argument( - '--ending-user-id', - type=int, - dest='ending_user_id', - default=0, - help='Ending user id (inclusive) to process a specific batch of users. ' - 'Both start and end id should be provided.', - ) - - def __init__(self): - super().__init__() - self.braze_client = BrazeClient( - api_key=settings.EDX_BRAZE_API_KEY, - api_url=settings.EDX_BRAZE_API_SERVER, - app_id='', - ) - - @staticmethod - def _chunks(users, chunk_size=TRACK_USER_COMPONENT_CHUNK_SIZE): - """ - Yields successive chunks of users. The size of each chunk is determined by - TRACK_USER_COMPONENT_CHUNK_SIZE which is set to 75. - Reference: https://www.braze.com/docs/api/endpoints/user_data/post_user_track/ - """ - for index in range(0, len(users), chunk_size): - yield users[index:index + chunk_size] - - def _get_user_batch(self, batch_start_id, batch_end_id): - """ - This returns the batch of users. - """ - query = User.objects.filter( - id__gte=batch_start_id, id__lt=batch_end_id, - ).select_related('profile').values_list( - 'id', 'email', named=True, - ).order_by('id') - - return use_read_replica_if_available(query) - - def _update_braze_attributes(self, users): - """ - Sends Braze API request to update user account. - Fields sent using the API include: - - external_id (user_id) - - email - """ - attributes = [] - for user in users: - attributes.append( - { - "external_id": user.id, - "email": user.email, - } - ) - - try: - self.braze_client.track_user(attributes=attributes) - except BrazeClientError as error: - logger.error(f'Failed to update attributes. Error: {error}') - - def handle(self, *args, **options): - """ - Handler to run the command. - """ - sleep_time = options['batch_delay'] - batch_size = options['batch_size'] - starting_user_id = options['starting_user_id'] - ending_user_id = options['ending_user_id'] - - all_users_query = use_read_replica_if_available(User.objects) - total_users_count = ending_user_id if ending_user_id else all_users_query.count() - - for index in range(starting_user_id, total_users_count + 1, batch_size): - users = self._get_user_batch(index, index + batch_size) - logger.info(f'Processing users with user ids in {index} - {(index + batch_size) - 1} range') - - # Force evaluating the query to avoid multiple hits to db - # when we evaluate the chunks. - evaluated_users = list(users) - for user_chunk in self._chunks(evaluated_users): - self._update_braze_attributes(user_chunk) - - time.sleep(sleep_time) diff --git a/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py b/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py deleted file mode 100644 index 8d8ad31c4c38..000000000000 --- a/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Unittests for populate_marketing_opt_in_user_attribute management command. -""" -from unittest.mock import patch, MagicMock - -from braze.exceptions import BrazeClientError -from django.core.management import call_command -from django.test import TestCase -from testfixtures import LogCapture - -from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangolib.testing.utils import skip_unless_lms - -LOGGER_NAME = 'common.djangoapps.student.management.commands.populate_users_emails_on_braze' - - -@skip_unless_lms -class TestPopulateUsersEmailsOnBraze(TestCase): - """ - Tests for PopulateUsersEmailsOnBraze management command. - """ - - @classmethod - def setUpClass(cls): - super().setUpClass() - for index in range(15): - user = UserFactory() - - @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') - def test_command_updates_users_on_braze(self, track_user): - """ - Test that Braze API is called successfully for all users - """ - track_user.return_value = MagicMock() - call_command('populate_users_emails_on_braze', batch_delay=0) - assert track_user.called - - @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') - def test_logs_for_success(self, track_user): - """ - Test logs for a successful run of updating user accounts on Braze - """ - track_user.return_value = MagicMock() - with LogCapture(LOGGER_NAME) as log: - call_command( - 'populate_users_emails_on_braze', - batch_size=5, - batch_delay=0, - ) - log.check( - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 0 - 4 range'), - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 5 - 9 range'), - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 10 - 14 range'), - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 15 - 19 range'), - ) - - @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') - def test_logs_for_failure(self, track_user): - """ - Test logs for when the update to Braze fails - """ - track_user.side_effect = BrazeClientError('Update to attributes failed.') - with LogCapture(LOGGER_NAME) as log: - call_command( - 'populate_users_emails_on_braze', - batch_size=5, - batch_delay=0, - ) - log.check_present( - (LOGGER_NAME, 'ERROR', 'Failed to update attributes. Error: Update to attributes failed.'), - ) - - @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') - def test_running_a_specific_batch(self, track_user): - """ - Test running command for a specific batch of users - """ - track_user.return_value = MagicMock() - with LogCapture(LOGGER_NAME) as log: - call_command( - 'populate_users_emails_on_braze', - batch_size=5, - batch_delay=0, - starting_user_id=2, - ending_user_id=13, - ) - log.check( - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 2 - 6 range'), - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 7 - 11 range'), - (LOGGER_NAME, 'INFO', 'Processing users with user ids in 12 - 16 range'), - ) diff --git a/common/djangoapps/student/signals/__init__.py b/common/djangoapps/student/signals/__init__.py index 9d134198f000..06ac888896e0 100644 --- a/common/djangoapps/student/signals/__init__.py +++ b/common/djangoapps/student/signals/__init__.py @@ -1,6 +1,8 @@ # lint-amnesty, pylint: disable=missing-module-docstring from common.djangoapps.student.signals.signals import ( + emit_course_access_role_added, + emit_course_access_role_removed, ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, REFUND_ORDER, diff --git a/common/djangoapps/student/signals/receivers.py b/common/djangoapps/student/signals/receivers.py index 079647c1451e..82300f4e3a3d 100644 --- a/common/djangoapps/student/signals/receivers.py +++ b/common/djangoapps/student/signals/receivers.py @@ -8,13 +8,14 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.db import IntegrityError -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from lms.djangoapps.courseware.toggles import courseware_mfe_progress_milestones_are_active from lms.djangoapps.utils import get_braze_client from common.djangoapps.student.helpers import EMAIL_EXISTS_MSG_FMT, USERNAME_EXISTS_MSG_FMT, AccountValidationError from common.djangoapps.student.models import ( + CourseAccessRole, CourseEnrollment, CourseEnrollmentCelebration, PendingNameChange, @@ -22,7 +23,11 @@ is_username_retired ) from common.djangoapps.student.models_api import confirm_name_change -from common.djangoapps.student.signals import USER_EMAIL_CHANGED +from common.djangoapps.student.signals import ( + emit_course_access_role_added, + emit_course_access_role_removed, + USER_EMAIL_CHANGED, +) from openedx.core.djangoapps.safe_sessions.middleware import EmailChangeMiddleware from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed @@ -87,6 +92,29 @@ def create_course_enrollment_celebration(sender, instance, created, **kwargs): pass +@receiver(post_save, sender=CourseAccessRole) +def on_course_access_role_created(sender, instance, created, **kwargs): + """ + Emit an event to the event-bus when a CourseAccessRole is created + """ + # Updating a role instance to a different role is unhandled behavior at the moment + # this event assumes roles are only created or deleted + if not created: + return + + user = instance.user + emit_course_access_role_added(user, instance.course_id, instance.org, instance.role) + + +@receiver(post_delete, sender=CourseAccessRole) +def listen_for_course_access_role_removed(sender, instance, **kwargs): + """ + Emit an event to the event-bus when a CourseAccessRole is deleted + """ + user = instance.user + emit_course_access_role_removed(user, instance.course_id, instance.org, instance.role) + + def listen_for_verified_name_approved(sender, user_id, profile_name, **kwargs): """ If the user has a pending name change that corresponds to an approved verified name, confirm it. diff --git a/common/djangoapps/student/signals/signals.py b/common/djangoapps/student/signals/signals.py index 49745ca48c92..15ccbe5cc0cd 100644 --- a/common/djangoapps/student/signals/signals.py +++ b/common/djangoapps/student/signals/signals.py @@ -5,6 +5,10 @@ from django.dispatch import Signal +from openedx_events.learning.data import CourseAccessRoleData, UserData, UserPersonalData +from openedx_events.learning.signals import COURSE_ACCESS_ROLE_ADDED, COURSE_ACCESS_ROLE_REMOVED + + # The purely documentational providing_args argument for Signal is deprecated. # So we are moving the args to a comment. @@ -21,3 +25,45 @@ REFUND_ORDER = Signal() USER_EMAIL_CHANGED = Signal() + + +def emit_course_access_role_added(user, course_id, org_key, role): + """ + Emit an event to the event-bus when a CourseAccessRole is added + """ + COURSE_ACCESS_ROLE_ADDED.send_event( + course_access_role_data=CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + ), + id=user.id, + is_active=user.is_active, + ), + course_key=course_id, + org_key=org_key, + role=role, + ) + ) + + +def emit_course_access_role_removed(user, course_id, org_key, role): + """ + Emit an event to the event-bus when a CourseAccessRole is deleted + """ + COURSE_ACCESS_ROLE_REMOVED.send_event( + course_access_role_data=CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=user.username, + email=user.email, + ), + id=user.id, + is_active=user.is_active, + ), + course_key=course_id, + org_key=org_key, + role=role, + ) + ) diff --git a/common/djangoapps/student/tests/test_events.py b/common/djangoapps/student/tests/test_events.py index fe11c4be7f79..f336396e6e71 100644 --- a/common/djangoapps/student/tests/test_events.py +++ b/common/djangoapps/student/tests/test_events.py @@ -4,32 +4,37 @@ from unittest import mock -import pytest +import ddt +import pytest from django.db.utils import IntegrityError from django.test import TestCase from django_countries.fields import Country - -from common.djangoapps.student.models import CourseEnrollmentAllowed, CourseEnrollment -from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory, UserProfileFactory -from common.djangoapps.student.tests.tests import UserSettingsEventTestMixin - +from opaque_keys.edx.keys import CourseKey from openedx_events.learning.data import ( # lint-amnesty, pylint: disable=wrong-import-order + CourseAccessRoleData, CourseData, CourseEnrollmentData, UserData, - UserPersonalData, + UserPersonalData ) from openedx_events.learning.signals import ( # lint-amnesty, pylint: disable=wrong-import-order + COURSE_ACCESS_ROLE_ADDED, + COURSE_ACCESS_ROLE_REMOVED, COURSE_ENROLLMENT_CHANGED, COURSE_ENROLLMENT_CREATED, - COURSE_UNENROLLMENT_COMPLETED, + COURSE_UNENROLLMENT_COMPLETED ) from openedx_events.tests.utils import OpenEdxEventsTestMixin # lint-amnesty, pylint: disable=wrong-import-order + +from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory, UserProfileFactory +from common.djangoapps.student.tests.tests import UserSettingsEventTestMixin from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangolib.testing.utils import skip_unless_lms - -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -377,3 +382,109 @@ def test_unenrollment_completed_event_emitted(self): }, event_receiver.call_args.kwargs ) + + +@skip_unless_lms +@ddt.ddt +class TestCourseAccessRoleEvents(TestCase, OpenEdxEventsTestMixin): + """ + Tests for the events associated with the CourseAccessRole model. + """ + ENABLED_OPENEDX_EVENTS = [ + 'org.openedx.learning.user.course_access_role.added.v1', + 'org.openedx.learning.user.course_access_role.removed.v1', + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.start_events_isolation() + + def setUp(self): + self.course_key = CourseKey.from_string("course-v1:test+blah+blah") + self.user = UserFactory.create( + username="test", + email="test@example.com", + password="password", + ) + self.receiver_called = False + + def _event_receiver_side_effect(self, **kwargs): # pylint: disable=unused-argument + """ + Used show that the Open edX Event was called by the Django signal handler. + """ + self.receiver_called = True + + @ddt.data( + CourseStaffRole, + CourseInstructorRole, + ) + def test_access_role_created_event_emitted(self, AccessRole): + """ + Event is emitted with the correct data when a CourseAccessRole is created. + """ + event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect) + COURSE_ACCESS_ROLE_ADDED.connect(event_receiver) + + role = AccessRole(self.course_key) + role.add_users(self.user) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": COURSE_ACCESS_ROLE_ADDED, + "sender": None, + "course_access_role_data": CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course_key=self.course_key, + org_key=self.course_key.org, + role=role._role_name, # pylint: disable=protected-access + ), + }, + event_receiver.call_args.kwargs + ) + + @ddt.data( + CourseStaffRole, + CourseInstructorRole, + ) + def test_access_role_removed_event_emitted(self, AccessRole): + """ + Event is emitted with the correct data when a CourseAccessRole is deleted. + """ + role = AccessRole(self.course_key) + role.add_users(self.user) + + # connect mock only after initial role is added + event_receiver = mock.Mock(side_effect=self._event_receiver_side_effect) + COURSE_ACCESS_ROLE_REMOVED.connect(event_receiver) + role.remove_users(self.user) + + self.assertTrue(self.receiver_called) + self.assertDictContainsSubset( + { + "signal": COURSE_ACCESS_ROLE_REMOVED, + "sender": None, + "course_access_role_data": CourseAccessRoleData( + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course_key=self.course_key, + org_key=self.course_key.org, + role=role._role_name, # pylint: disable=protected-access + ), + }, + event_receiver.call_args.kwargs + ) diff --git a/common/djangoapps/third_party_auth/api/tests/test_permissions.py b/common/djangoapps/third_party_auth/api/tests/test_permissions.py index 120abc17aa82..1cb9450c49a2 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_permissions.py +++ b/common/djangoapps/third_party_auth/api/tests/test_permissions.py @@ -4,9 +4,7 @@ import ddt from django.test import RequestFactory, TestCase -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt -from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from rest_framework.views import APIView @@ -25,7 +23,6 @@ class ThirdPartyAuthPermissionTest(TestCase): class SomeTpaClassView(APIView): """view used to test TPA_permissions""" - authentication_classes = (JwtAuthentication, SessionAuthentication) permission_classes = (TPA_PERMISSIONS,) required_scopes = ['tpa:read'] diff --git a/common/djangoapps/third_party_auth/saml_configuration/views.py b/common/djangoapps/third_party_auth/saml_configuration/views.py index aa051aac7f97..b6e6c39ffe2a 100644 --- a/common/djangoapps/third_party_auth/saml_configuration/views.py +++ b/common/djangoapps/third_party_auth/saml_configuration/views.py @@ -2,16 +2,13 @@ Viewset for auth/saml/v0/saml_configuration """ -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import permissions, viewsets -from rest_framework.authentication import SessionAuthentication from ..models import SAMLConfiguration from .serializers import SAMLConfigurationSerializer class SAMLConfigurationMixin: - authentication_classes = (JwtAuthentication, SessionAuthentication,) permission_classes = (permissions.IsAuthenticated,) serializer_class = SAMLConfigurationSerializer diff --git a/common/djangoapps/third_party_auth/samlproviderconfig/views.py b/common/djangoapps/third_party_auth/samlproviderconfig/views.py index 08732f095093..7286402df5bd 100644 --- a/common/djangoapps/third_party_auth/samlproviderconfig/views.py +++ b/common/djangoapps/third_party_auth/samlproviderconfig/views.py @@ -5,10 +5,8 @@ from django.shortcuts import get_list_or_404 from django.db.utils import IntegrityError from edx_rbac.mixins import PermissionRequiredMixin -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import permissions, viewsets, status from rest_framework.response import Response -from rest_framework.authentication import SessionAuthentication from rest_framework.exceptions import ParseError, ValidationError from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomer @@ -20,7 +18,6 @@ class SAMLProviderMixin: - authentication_classes = [JwtAuthentication, SessionAuthentication] permission_classes = [permissions.IsAuthenticated] serializer_class = SAMLProviderConfigSerializer diff --git a/common/djangoapps/third_party_auth/samlproviderdata/views.py b/common/djangoapps/third_party_auth/samlproviderdata/views.py index f61b237c1212..b5d044bd0498 100644 --- a/common/djangoapps/third_party_auth/samlproviderdata/views.py +++ b/common/djangoapps/third_party_auth/samlproviderdata/views.py @@ -8,10 +8,8 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from edx_rbac.mixins import PermissionRequiredMixin -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from enterprise.models import EnterpriseCustomerIdentityProvider from rest_framework import permissions, status, viewsets -from rest_framework.authentication import SessionAuthentication from rest_framework.decorators import action from rest_framework.exceptions import ParseError from rest_framework.response import Response @@ -31,7 +29,6 @@ class SAMLProviderDataMixin: - authentication_classes = [JwtAuthentication, SessionAuthentication] permission_classes = [permissions.IsAuthenticated] serializer_class = SAMLProviderDataSerializer diff --git a/lms/djangoapps/bulk_user_retirement/views.py b/lms/djangoapps/bulk_user_retirement/views.py index 14775ac32267..8207314aec45 100644 --- a/lms/djangoapps/bulk_user_retirement/views.py +++ b/lms/djangoapps/bulk_user_retirement/views.py @@ -3,7 +3,6 @@ """ import logging -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from django.contrib.auth import get_user_model from django.db import transaction from rest_framework import permissions, status @@ -34,7 +33,6 @@ class BulkUsersRetirementView(APIView): * usernames: Comma separated strings of usernames that should be retired. """ - authentication_classes = (JwtAuthentication, ) permission_classes = (permissions.IsAuthenticated, CanRetireUser) def post(self, request, **kwargs): # pylint: disable=unused-argument diff --git a/lms/djangoapps/commerce/api/v1/views.py b/lms/djangoapps/commerce/api/v1/views.py index 0e634ee36207..591f266b48a2 100644 --- a/lms/djangoapps/commerce/api/v1/views.py +++ b/lms/djangoapps/commerce/api/v1/views.py @@ -72,7 +72,6 @@ def pre_save(self, obj): class OrderView(APIView): """ Retrieve order details. """ - authentication_classes = (JwtAuthentication, SessionAuthentication,) permission_classes = (IsAuthenticatedOrActivationOverridden,) def get(self, request, number): diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index 813bde793f88..91c8a6d7f15b 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -376,7 +376,6 @@ def finalize_response(self, request, response, *args, **kwargs): @api_view(['POST']) -@authentication_classes((JwtAuthentication,)) @permission_classes((IsAuthenticated,)) def dismiss_welcome_message(request): # pylint: disable=missing-function-docstring course_id = request.data.get('course_id', None) diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 19d6965e75c0..1d6c358ffd19 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -997,10 +997,15 @@ def test_zendesk_submission_failed(self, _mock_create_zendesk_ticket): ) @ddt.data( ('/financial-assistance/course-v1:test+TestX+Test_Course/apply/', status.HTTP_204_NO_CONTENT), + ('/financial-assistance/course-v1:test+TestX+Test_Course/apply/', status.HTTP_403_FORBIDDEN), ('/financial-assistance/course-v1:invalid+ErrorX+Invalid_Course/apply/', status.HTTP_400_BAD_REQUEST) ) @ddt.unpack def test_submit_financial_assistance_request_v2(self, referrer_url, expected_status, *args): + # We expect a 403 if the user account is not active + if expected_status == status.HTTP_403_FORBIDDEN: + self.user.is_active = False + self.user.save() form_data = { 'username': self.user.username, 'course': 'course-v1:test+TestX+Test_Course', diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index e4fbdb33a83a..3d70a763582d 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -2013,6 +2013,10 @@ def financial_assistance_request(request): username = data['username'] if request.user.username != username: return HttpResponseForbidden() + # Require email verification + if request.user.is_active is not True: + logging.warning('FA_v1: User %s tried to submit app without activating their account.', username) + return HttpResponseForbidden('Please confirm your email before applying for financial assistance.') course_id = data['course'] course = modulestore().get_course(CourseKey.from_string(course_id)) @@ -2085,6 +2089,10 @@ def financial_assistance_request_v2(request): # submitting an FA request if request.user.username != username: return HttpResponseForbidden() + # Require email verification + if request.user.is_active is not True: + logging.warning('FA_v2: User %s tried to submit app without activating their account.', username) + return HttpResponseForbidden('Please confirm your email before applying for financial assistance.') course_id = data['course'] if course_id and course_id not in request.META.get('HTTP_REFERER'): diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index dc888729bb9c..f8a5f6288c51 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -93,6 +93,7 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se is_thread = cc_content["type"] == "thread" is_comment = cc_content["type"] == "comment" has_moderation_privilege = context["has_moderation_privilege"] + is_staff_or_admin = context["is_staff_or_admin"] if is_thread: is_thread_closed = cc_content["closed"] @@ -107,7 +108,7 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se "abuse_flagged": True, "closed": is_thread and has_moderation_privilege, "close_reason_code": is_thread and has_moderation_privilege, - "pinned": is_thread and has_moderation_privilege, + "pinned": is_thread and (has_moderation_privilege or is_staff_or_admin), "read": is_thread, } if is_thread: diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index 5f18281c422c..4ac573e76670 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -85,6 +85,7 @@ def get_context(course, request, thread=None): "cc_requester": cc_requester, "has_moderation_privilege": has_moderation_privilege, "is_global_staff": is_global_staff, + "is_staff_or_admin": requester.id in course_staff_user_ids, } diff --git a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py index 5ad67619ef64..756229150e30 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py @@ -27,6 +27,7 @@ def _get_context( allow_anonymous=True, allow_anonymous_to_peers=False, has_moderation_privilege=False, + is_staff_or_admin=False, ): """Return a context suitable for testing the permissions module""" return { @@ -39,6 +40,7 @@ def _get_context( "discussion_division_enabled": is_cohorted, "thread": thread, "has_moderation_privilege": has_moderation_privilege, + "is_staff_or_admin": is_staff_or_admin, } @@ -96,7 +98,7 @@ def test_comment(self, is_thread_author, thread_type, is_privileged): @ddt.ddt class GetEditableFieldsTest(ModuleStoreTestCase): """Tests for get_editable_fields""" - @ddt.data(*itertools.product(*[[True, False] for _ in range(5)])) + @ddt.data(*itertools.product(*[[True, False] for _ in range(6)])) @ddt.unpack def test_thread( self, @@ -105,6 +107,7 @@ def test_thread( allow_anonymous, allow_anonymous_to_peers, has_moderation_privilege, + is_staff_or_admin, ): thread = Thread(user_id="5" if is_author else "6", type="thread") context = _get_context( @@ -113,11 +116,14 @@ def test_thread( allow_anonymous=allow_anonymous, allow_anonymous_to_peers=allow_anonymous_to_peers, has_moderation_privilege=has_moderation_privilege, + is_staff_or_admin=is_staff_or_admin, ) actual = get_editable_fields(thread, context) expected = {"abuse_flagged", "copy_link", "following", "read", "voted"} if has_moderation_privilege: - expected |= {"closed", "pinned", "close_reason_code"} + expected |= {"closed", "close_reason_code"} + if has_moderation_privilege or is_staff_or_admin: + expected |= {"pinned"} if has_moderation_privilege and not is_author: expected |= {"edit_reason_code"} if is_author or has_moderation_privilege: diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 1782a574b05a..1ebed6380de4 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -675,7 +675,7 @@ def test_auth(self): # Test unauthenticated response = self.client.post(self.url, data) - assert response.status_code == 401 + assert response.status_code == 403 # Test non-service worker random_user = UserFactory() diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index bcfc9c902dd6..b62356a45dba 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1099,7 +1099,6 @@ class RetireUserView(APIView): Empty string """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser) def post(self, request): @@ -1147,7 +1146,6 @@ class ReplaceUsernamesView(APIView): """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanReplaceUsername) def post(self, request): diff --git a/lms/djangoapps/edxnotes/views.py b/lms/djangoapps/edxnotes/views.py index 54b1fa65f42d..3e23ebe9ab47 100644 --- a/lms/djangoapps/edxnotes/views.py +++ b/lms/djangoapps/edxnotes/views.py @@ -11,7 +11,6 @@ from django.http import Http404, HttpResponse from django.urls import reverse from django.views.decorators.http import require_GET -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status from rest_framework.response import Response @@ -244,7 +243,6 @@ class RetireUserView(APIView): - EdxNotesServiceUnavailable is thrown: the edx-notes-api IDA is not available. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser) def post(self, request): diff --git a/lms/djangoapps/instructor_task/rest_api/v1/views.py b/lms/djangoapps/instructor_task/rest_api/v1/views.py index 3fcd226c9c0e..812b88e11da3 100644 --- a/lms/djangoapps/instructor_task/rest_api/v1/views.py +++ b/lms/djangoapps/instructor_task/rest_api/v1/views.py @@ -9,8 +9,6 @@ import dateutil from celery.states import REVOKED from django.db import transaction -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from rest_framework import generics, status @@ -35,10 +33,6 @@ class ListScheduledBulkEmailInstructorTasks(generics.ListAPIView): data also includes information about the and course email instance associated with each task. * 403: User does not have the required role to view this data. """ - authentication_classes = ( - JwtAuthentication, - SessionAuthentication, - ) permission_classes = ( CanViewOrModifyScheduledBulkCourseEmailTasks, ) @@ -74,10 +68,6 @@ class ModifyScheduledBulkEmailInstructorTask(generics.DestroyAPIView, generics.U * 403: User does not have permission to modify the object specified. * 404: Requested schedule object could not be found and thus could not be modified or removed. """ - authentication_classes = ( - JwtAuthentication, - SessionAuthentication, - ) permission_classes = ( CanViewOrModifyScheduledBulkCourseEmailTasks, ) diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index 92dac75806bf..1579fdd26a69 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -1,9 +1,7 @@ """ API v0 views. """ import logging -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from enterprise.models import EnterpriseCourseEnrollment -from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -81,8 +79,6 @@ class Programs(APIView): ] """ - authentication_classes = (JwtAuthentication, SessionAuthentication,) - permission_classes = (IsAuthenticated,) def get(self, request, enterprise_uuid): @@ -298,11 +294,6 @@ class ProgramProgressDetailView(APIView): } """ - authentication_classes = ( - JwtAuthentication, - SessionAuthentication, - ) - permission_classes = (IsAuthenticated,) def get(self, request, program_uuid): diff --git a/lms/djangoapps/support/views/feature_based_enrollments.py b/lms/djangoapps/support/views/feature_based_enrollments.py index 929c2a30eadc..af5861ac5e27 100644 --- a/lms/djangoapps/support/views/feature_based_enrollments.py +++ b/lms/djangoapps/support/views/feature_based_enrollments.py @@ -2,10 +2,8 @@ Support tool for viewing course duration information """ -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from django.utils.decorators import method_decorator from django.views.generic import View -from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.generics import GenericAPIView @@ -43,9 +41,6 @@ class FeatureBasedEnrollmentSupportAPIView(GenericAPIView): Support-only API View for getting feature based enrollment configuration details for a course. """ - authentication_classes = ( - JwtAuthentication, SessionAuthentication - ) permission_classes = (IsAuthenticated,) @method_decorator(require_support_permission) diff --git a/lms/djangoapps/support/views/program_enrollments.py b/lms/djangoapps/support/views/program_enrollments.py index c91242319702..d5b264069b68 100644 --- a/lms/djangoapps/support/views/program_enrollments.py +++ b/lms/djangoapps/support/views/program_enrollments.py @@ -6,9 +6,7 @@ from django.db.models import Q from django.utils.decorators import method_decorator from django.views.generic import View -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework.views import APIView -from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from social_django.models import UserSocialAuth @@ -77,9 +75,6 @@ class LinkProgramEnrollmentSupportAPIView(APIView): """ Support-only API View for linking learner enrollments by support staff. """ - authentication_classes = ( - JwtAuthentication, SessionAuthentication - ) permission_classes = ( IsAuthenticated, ) @@ -312,9 +307,6 @@ class ProgramEnrollmentsInspectorAPIView(ProgramEnrollmentInspector, APIView): information of a learner. """ - authentication_classes = ( - JwtAuthentication, SessionAuthentication - ) permission_classes = ( IsAuthenticated, ) diff --git a/lms/djangoapps/teams/static/teams/js/views/team_profile.js b/lms/djangoapps/teams/static/teams/js/views/team_profile.js index 8afa8df0165f..0ff32b911489 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_profile.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_profile.js @@ -44,13 +44,14 @@ isMember = TeamUtils.isUserMemberOfTeam(memberships, this.context.userInfo.username), isAdminOrStaff = this.context.userInfo.privileged || this.context.userInfo.staff, isInstructorManagedTopic = TeamUtils.isInstructorManagedTopic(this.topic.attributes.type), + canJoinTeam = TeamUtils.canJoinTeam(this.context.userInfo, this.topic.attributes.type), maxTeamSize = this.topic.getMaxTeamSize(this.context.courseMaxTeamSize); // Assignments URL isn't provided if team assignments shouldn't be shown // so we can treat it like a toggle var showAssignments = !!this.context.teamsAssignmentsUrl; - var showLeaveLink = isMember && (isAdminOrStaff || !isInstructorManagedTopic); + var showLeaveLink = isMember && (isAdminOrStaff || !isInstructorManagedTopic || canJoinTeam); HtmlUtils.setHtml( this.$el, diff --git a/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js b/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js index e860377dd39d..75fc8ad92db3 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_profile_header_actions.js @@ -47,6 +47,8 @@ } else if (!teamHasSpace) { showJoinButton = false; message = view.teamFullMessage; + } else if (info.canJoinTeam) { + showJoinButton = true; } else if (!info.isAdminOrStaff && info.isInstructorManagedTopic) { showJoinButton = false; message = view.notJoinInstructorManagedTeam; @@ -100,12 +102,14 @@ // this.topic.getMaxTeamSize() will return null for a managed team, // but the size is considered to be arbitarily large. var isInstructorManagedTopic = TeamUtils.isInstructorManagedTopic(this.topic.attributes.type); + var canJoinTeam = TeamUtils.canJoinTeam(this.context.userInfo, this.topic.attributes.type) var teamHasSpace = isInstructorManagedTopic || (this.model.get('membership').length < this.topic.getMaxTeamSize(courseMaxTeamSize)); info.memberOfCurrentTeam = TeamUtils.isUserMemberOfTeam(this.model.get('membership'), username); info.isAdminOrStaff = this.context.userInfo.privileged || this.context.userInfo.staff; info.isInstructorManagedTopic = isInstructorManagedTopic; + info.canJoinTeam = canJoinTeam; if (info.memberOfCurrentTeam) { info.alreadyInTeamset = true; diff --git a/lms/djangoapps/teams/static/teams/js/views/team_utils.js b/lms/djangoapps/teams/static/teams/js/views/team_utils.js index ae1d9c118d95..3222afc03e6b 100644 --- a/lms/djangoapps/teams/static/teams/js/views/team_utils.js +++ b/lms/djangoapps/teams/static/teams/js/views/team_utils.js @@ -83,6 +83,10 @@ return topicType.toLowerCase() !== 'open'; }, + canJoinTeam: function(userInfo, topicType = '') { + return userInfo.privileged || userInfo.staff || topicType.includes("open"); + }, + /** Shows info/error banner for team membership CSV upload * @param: content - string or array for display * @param: isError - true sets error styling, false/none uses info styling diff --git a/lms/djangoapps/teams/tests/test_api.py b/lms/djangoapps/teams/tests/test_api.py index e9df8cd222f2..dceb940ef560 100644 --- a/lms/djangoapps/teams/tests/test_api.py +++ b/lms/djangoapps/teams/tests/test_api.py @@ -24,6 +24,7 @@ TOPIC1 = 'topic-1' TOPIC2 = 'topic-2' TOPIC3 = 'topic-3' +TOPIC4 = 'topic-4' DISCUSSION_TOPIC_ID = uuid4().hex @@ -44,7 +45,8 @@ def setUpClass(cls): topic_data = [ (TOPIC1, TeamsetType.private_managed.value), (TOPIC2, TeamsetType.open.value), - (TOPIC3, TeamsetType.public_managed.value) + (TOPIC3, TeamsetType.public_managed.value), + (TOPIC4, TeamsetType.open_managed.value), ] topics = [ { @@ -55,7 +57,7 @@ def setUpClass(cls): } for topic_id, teamset_type in topic_data ] teams_config_1 = TeamsConfig({'topics': [topics[0]]}) - teams_config_2 = TeamsConfig({'topics': [topics[1], topics[2]]}) + teams_config_2 = TeamsConfig({'topics': [topics[1], topics[2], topics[3]]}) cls.course1 = CourseFactory( org=COURSE_KEY1.org, course=COURSE_KEY1.course, @@ -93,10 +95,12 @@ def setUpClass(cls): topic_id=TOPIC2 ) cls.team3 = CourseTeamFactory(course_id=COURSE_KEY2, team_id='team3', topic_id=TOPIC3) + cls.team4 = CourseTeamFactory(course_id=COURSE_KEY2, team_id='team4', topic_id=TOPIC4) cls.team1.add_user(cls.user1) cls.team1.add_user(cls.user2) cls.team2.add_user(cls.user3) + cls.team4.add_user(cls.user3) cls.team1a.add_user(cls.user4) cls.team2a.add_user(cls.user4) @@ -122,21 +126,25 @@ def test_is_team_discussion_private_is_public(self): assert not teams_api.is_team_discussion_private(None) assert not teams_api.is_team_discussion_private(self.team2) assert not teams_api.is_team_discussion_private(self.team3) + assert not teams_api.is_team_discussion_private(self.team4) def test_is_instructor_managed_team(self): assert teams_api.is_instructor_managed_team(self.team1) assert not teams_api.is_instructor_managed_team(self.team2) assert teams_api.is_instructor_managed_team(self.team3) + assert not teams_api.is_instructor_managed_team(self.team4) def test_is_instructor_managed_topic(self): assert teams_api.is_instructor_managed_topic(COURSE_KEY1, TOPIC1) assert not teams_api.is_instructor_managed_topic(COURSE_KEY2, TOPIC2) assert teams_api.is_instructor_managed_topic(COURSE_KEY2, TOPIC3) + assert not teams_api.is_instructor_managed_topic(COURSE_KEY2, TOPIC4) def test_user_is_a_team_member(self): assert teams_api.user_is_a_team_member(self.user1, self.team1) assert not teams_api.user_is_a_team_member(self.user1, None) assert not teams_api.user_is_a_team_member(self.user1, self.team2) + assert not teams_api.user_is_a_team_member(self.user1, self.team4) def test_private_discussion_visible_by_user(self): assert teams_api.discussion_visible_by_user(DISCUSSION_TOPIC_ID, self.user1) @@ -147,6 +155,7 @@ def test_public_discussion_visible_by_user(self): assert teams_api.discussion_visible_by_user(self.team2.discussion_topic_id, self.user1) assert teams_api.discussion_visible_by_user(self.team2.discussion_topic_id, self.user2) assert teams_api.discussion_visible_by_user('DO_NOT_EXISTS', self.user3) + assert teams_api.discussion_visible_by_user(self.team4.discussion_topic_id, self.user3) @ddt.unpack @ddt.data( diff --git a/lms/djangoapps/teams/views.py b/lms/djangoapps/teams/views.py index a5370ffa937f..dcce36382096 100644 --- a/lms/djangoapps/teams/views.py +++ b/lms/djangoapps/teams/views.py @@ -197,7 +197,10 @@ def get(self, request, course_id): "teams": user_teams_data }, "has_open_teamset": bool(teamset_counts_by_type[TeamsetType.open.value]), - "has_public_managed_teamset": bool(teamset_counts_by_type[TeamsetType.public_managed.value]), + "has_public_managed_teamset": bool( + teamset_counts_by_type[TeamsetType.public_managed.value] + + teamset_counts_by_type[TeamsetType.open_managed.value] + ), "has_managed_teamset": bool( teamset_counts_by_type[TeamsetType.public_managed.value] + teamset_counts_by_type[TeamsetType.private_managed.value] diff --git a/lms/djangoapps/user_tours/v1/views.py b/lms/djangoapps/user_tours/v1/views.py index ce4c354e5dc2..65db60bcacf8 100644 --- a/lms/djangoapps/user_tours/v1/views.py +++ b/lms/djangoapps/user_tours/v1/views.py @@ -2,8 +2,6 @@ from django.conf import settings from django.db import transaction, IntegrityError from django.shortcuts import get_object_or_404 -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from rest_framework.authentication import SessionAuthentication from rest_framework.generics import RetrieveUpdateAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -25,7 +23,6 @@ class UserTourView(RetrieveUpdateAPIView): GET /api/user_tours/v1/{username} PATCH /api/user_tours/v1/{username} """ - authentication_classes = (JwtAuthentication,) permission_classes = (IsAuthenticated,) serializer_class = UserTourSerializer @@ -111,7 +108,6 @@ class UserDiscussionsToursView(APIView): ] """ - authentication_classes = (JwtAuthentication, SessionAuthentication) permission_classes = (IsAuthenticated,) def get(self, request, tour_id=None): diff --git a/lms/envs/common.py b/lms/envs/common.py index 265019814547..0ef257df0023 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -349,9 +349,8 @@ # .. toggle_implementation: DjangoSetting # .. toggle_default: False # .. toggle_description: Set to True to perform acceptance and load test. Auto auth view is responsible for load - # testing and is controlled by this feature flag. Auto-auth causes issues in Bok Choy tests because it resets the - # requesting user. Session verification (of CacheBackedAuthenticationMiddleware) is a security feature, but it - # can be turned off by enabling this feature flag. + # testing and is controlled by this feature flag. Session verification (of CacheBackedAuthenticationMiddleware) + # is a security feature, but it can be turned off by enabling this feature flag. # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2013-07-25 # .. toggle_warning: If this has been set to True then the account activation email will be skipped. @@ -1148,7 +1147,7 @@ 'KEY_PREFIX': 'course_structure', 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', 'LOCATION': ['localhost:11211'], - 'TIMEOUT': '7200', + 'TIMEOUT': '604800', # 1 week 'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache', 'OPTIONS': { 'no_delay': True, @@ -5455,6 +5454,14 @@ def _should_send_certificate_events(settings): # .. toggle_tickets: https://github.com/openedx/openedx-events/issues/210 'enabled': False} }, + 'org.openedx.learning.user.course_access_role.added.v1': { + 'learning-course-access-role-lifecycle': + {'event_key_field': 'course_access_role_data.course_key', 'enabled': False}, + }, + 'org.openedx.learning.user.course_access_role.removed.v1': { + 'learning-course-access-role-lifecycle': + {'event_key_field': 'course_access_role_data.course_key', 'enabled': False}, + }, # CMS events. These have to be copied over here because cms.common adds some derived entries as well, # and the derivation fails if the keys are missing. If we ever fully decouple the lms and cms settings, # we can remove these. diff --git a/lms/envs/devstack-experimental.yml b/lms/envs/devstack-experimental.yml index 63812686e86a..149cf0982132 100644 --- a/lms/envs/devstack-experimental.yml +++ b/lms/envs/devstack-experimental.yml @@ -102,7 +102,7 @@ CACHES: KEY_PREFIX: course_structure LOCATION: - edx.devstack.memcached:11211 - TIMEOUT: '7200' + TIMEOUT: '604800' default: BACKEND: django.core.cache.backends.memcached.PyMemcacheCache OPTIONS: diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index e922d574ec5c..29369c2da8fe 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -238,6 +238,9 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ): AUTHENTICATION_BACKENDS = ['common.djangoapps.third_party_auth.dummy.DummyBackend'] + list(AUTHENTICATION_BACKENDS) +########################## Authn MFE Context API ####################### +ENABLE_DYNAMIC_REGISTRATION_FIELDS = True + ############## ECOMMERCE API CONFIGURATION SETTINGS ############### ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:18130' ECOMMERCE_API_URL = 'http://edx.devstack.ecommerce:18130/api/v2' @@ -518,6 +521,15 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing certificate_created_event_config = EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.certificate.created.v1'] certificate_created_event_config['learning-certificate-lifecycle']['enabled'] = True +course_access_role_added_event_setting = EVENT_BUS_PRODUCER_CONFIG[ + 'org.openedx.learning.user.course_access_role.added.v1' +] +course_access_role_added_event_setting['learning-course-access-role-lifecycle']['enabled'] = True +course_access_role_removed_event_setting = EVENT_BUS_PRODUCER_CONFIG[ + 'org.openedx.learning.user.course_access_role.removed.v1' +] +course_access_role_removed_event_setting['learning-course-access-role-lifecycle']['enabled'] = True + ######################## Subscriptions API SETTINGS ######################## SUBSCRIPTIONS_ROOT_URL = "http://host.docker.internal:18750" SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" @@ -545,6 +557,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing 'http://localhost:1997', # frontend-app-account 'http://localhost:1995', # frontend-app-profile 'http://localhost:1992', # frontend-app-ora + 'http://localhost:2002', # frontend-app-discussions ] diff --git a/lms/static/sass/lms-course.scss b/lms/static/sass/lms-course.scss index 6069fc52695a..b9ef46bacc44 100644 --- a/lms/static/sass/lms-course.scss +++ b/lms/static/sass/lms-course.scss @@ -24,3 +24,7 @@ } } } + +.percentage { + white-space: nowrap; +} diff --git a/openedx/core/djangoapps/agreements/views.py b/openedx/core/djangoapps/agreements/views.py index 82de8caabf00..cc928669ffdd 100644 --- a/openedx/core/djangoapps/agreements/views.py +++ b/openedx/core/djangoapps/agreements/views.py @@ -3,11 +3,9 @@ """ from django.conf import settings -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from opaque_keys.edx.keys import CourseKey @@ -34,7 +32,6 @@ class AuthenticatedAPIView(APIView): """ Authenticated API View. """ - authentication_classes = (SessionAuthentication, JwtAuthentication) permission_classes = (IsAuthenticated,) diff --git a/openedx/core/djangoapps/cache_toolbox/middleware.py b/openedx/core/djangoapps/cache_toolbox/middleware.py index 9d1ea2bf0690..050db6368558 100644 --- a/openedx/core/djangoapps/cache_toolbox/middleware.py +++ b/openedx/core/djangoapps/cache_toolbox/middleware.py @@ -135,8 +135,7 @@ def _verify_session_auth(self, request): """ Ensure that the user's session hash hasn't changed. """ - # Auto-auth causes issues in Bok Choy tests because it resets - # the requesting user. Since session verification is a + # Since session verification is a # security feature, we can turn it off when auto-auth is # enabled since auto-auth is highly insecure and only for # tests. diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py index 7e0ab60e41b4..50fec093c1fb 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py @@ -27,7 +27,7 @@ name="taxonomy-import-template", ), path( - "object_tags//export/", + "object_tags//export/", views.ObjectTagExportView.as_view(), ), path('', include(router.urls)) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index 92c51911db37..cdedc5b25381 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -149,11 +149,11 @@ class ObjectTagOrgView(ObjectTagView): class ObjectTagExportView(APIView): """" - View to export a CSV with all children and tags for a given object_id. + View to export a CSV with all children and tags for a given course/context. """ def get(self, request: Request, **kwargs) -> StreamingHttpResponse: """ - Export a CSV with all children and tags for a given object_id. + Export a CSV with all children and tags for a given course/context. """ class Echo(object): @@ -196,12 +196,12 @@ def _generate_csv_rows() -> Iterator[str]: yield csv_writer.writerow(block_data) - object_id: str = kwargs.get('object_id', None) + object_id: str = kwargs.get('context_id', None) try: content_key = CourseKey.from_string(object_id) except InvalidKeyError as e: - raise ValidationError("object_id is not a valid content key.") from e + raise ValidationError("context_id is not a valid course key.") from e # Check if the user has permission to view object tags for this object_id try: diff --git a/openedx/core/djangoapps/demographics/rest_api/v1/views.py b/openedx/core/djangoapps/demographics/rest_api/v1/views.py index ab114c41f4cd..35aacc61bd24 100644 --- a/openedx/core/djangoapps/demographics/rest_api/v1/views.py +++ b/openedx/core/djangoapps/demographics/rest_api/v1/views.py @@ -1,7 +1,5 @@ # lint-amnesty, pylint: disable=missing-module-docstring -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from rest_framework import permissions, status -from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from rest_framework.views import APIView @@ -18,7 +16,6 @@ class DemographicsStatusView(APIView): The API will return whether or not to display the Demographics UI based on the User's status in the Platform """ - authentication_classes = (JwtAuthentication, SessionAuthentication) permission_classes = (permissions.IsAuthenticated, ) def _response_context(self, user, user_demographics=None): diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index f413cb761e14..52ec4e3b3133 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -421,7 +421,6 @@ class UnenrollmentView(APIView): If the request is successful, an HTTP 200 "OK" response is returned along with a list of all courses from which the user was unenrolled. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) def post(self, request): @@ -1023,9 +1022,6 @@ class EnrollmentAllowedView(APIView): """ A view that allows the retrieval and creation of enrollment allowed for a given user email and course id. """ - authentication_classes = ( - JwtAuthentication, - ) permission_classes = (permissions.IsAdminUser,) throttle_classes = (EnrollmentUserThrottle,) serializer_class = CourseEnrollmentAllowedSerializer diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 946314cfa0bd..510d2b9125de 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext_lazy as _ from .utils import find_app_in_normalized_apps, find_pref_in_normalized_prefs +from ..django_comment_common.models import FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE = 'filter_audit_expired_users_with_no_role' @@ -131,6 +132,7 @@ 'replier_name': 'replier name', }, 'email_template': '', + 'visible_to': [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] }, 'response_endorsed_on_thread': { 'notification_app': 'discussion', diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 957837c57873..23eb412487e8 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -36,6 +36,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from ..base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, NotificationAppManager +from ..utils import get_notification_types_with_visibility_settings @ddt.ddt @@ -281,7 +282,9 @@ def test_get_user_notification_preference(self, mock_emit): self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.path) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, self._expected_api_response()) + expected_response = self._expected_api_response() + expected_response = remove_notifications_with_visibility_settings(expected_response) + self.assertEqual(response.data, expected_response) event_name, event_data = mock_emit.call_args[0] self.assertEqual(event_name, 'edx.notifications.preferences.viewed') @@ -317,9 +320,8 @@ def test_get_user_notification_preference_with_visibility_settings(self, role, m self.assertEqual(response.status_code, status.HTTP_200_OK) expected_response = self._expected_api_response() if not role: - expected_response['notification_preference_config']['discussion']['notification_types'].pop( - 'new_question_post' - ) + expected_response = remove_notifications_with_visibility_settings(expected_response) + self.assertEqual(response.data, expected_response) event_name, event_data = mock_emit.call_args[0] self.assertEqual(event_name, 'edx.notifications.preferences.viewed') @@ -360,11 +362,13 @@ def test_patch_user_notification_preference( if update_type == 'app_update': expected_data = self._expected_api_response() + expected_data = remove_notifications_with_visibility_settings(expected_data) expected_data['notification_preference_config'][notification_app]['enabled'] = value self.assertEqual(response.data, expected_data) elif update_type == 'type_update': expected_data = self._expected_api_response() + expected_data = remove_notifications_with_visibility_settings(expected_data) expected_data['notification_preference_config'][notification_app][ 'notification_types'][notification_type][notification_channel] = value self.assertEqual(response.data, expected_data) @@ -914,3 +918,15 @@ def test_mark_notification_read_without_app_name_and_notification_id(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data, {'error': 'Invalid app_name or notification_id.'}) + + +def remove_notifications_with_visibility_settings(expected_response): + """ + Remove notifications with visibility settings from the expected response. + """ + not_visible = get_notification_types_with_visibility_settings() + for notification_type, visibility_settings in not_visible.items(): + expected_response['notification_preference_config']['discussion']['notification_types'].pop( + notification_type + ) + return expected_response diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 0f0a2a6019bb..cfe9872a95f8 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -456,7 +456,6 @@ class NameChangeView(ViewSet): """ Viewset to manage profile name change requests. """ - authentication_classes = (JwtAuthentication, SessionAuthentication,) permission_classes = (permissions.IsAuthenticated,) def create(self, request): @@ -514,7 +513,6 @@ class AccountDeactivationView(APIView): Account deactivation viewset. Currently only supports POST requests. Only admins can deactivate accounts. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanDeactivateUser) def post(self, request, username): @@ -693,7 +691,6 @@ class AccountRetirementPartnerReportView(ViewSet): ORIGINAL_NAME_KEY = 'original_name' STUDENT_ID_KEY = 'student_id' - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) parser_classes = (JSONParser,) serializer_class = UserRetirementStatusSerializer @@ -831,7 +828,6 @@ class CancelAccountRetirementStatusView(ViewSet): """ Provides API endpoints for canceling retirement process for a user's account. """ - authentication_classes = (JwtAuthentication, SessionAuthentication) permission_classes = (permissions.IsAuthenticated, CanCancelUserRetirement,) def cancel_retirement(self, request): @@ -873,7 +869,6 @@ class AccountRetirementStatusView(ViewSet): """ Provides API endpoints for managing the user retirement process. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) parser_classes = (JSONParser,) serializer_class = UserRetirementStatusSerializer @@ -1080,7 +1075,6 @@ class LMSAccountRetirementView(ViewSet): """ Provides an API endpoint for retiring a user in the LMS. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) parser_classes = (JSONParser,) @@ -1136,7 +1130,6 @@ class AccountRetirementView(ViewSet): """ Provides API endpoint for retiring a user. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanRetireUser,) parser_classes = (JSONParser,) @@ -1276,7 +1269,6 @@ class UsernameReplacementView(APIView): This API will be called first, before calling the APIs in other services as this one handles the checks on the usernames provided. """ - authentication_classes = (JwtAuthentication,) permission_classes = (permissions.IsAuthenticated, CanReplaceUsername) def post(self, request): diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index 04b0ae678f65..c7889d96a7e4 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -590,7 +590,7 @@ def post(self, request): redirect_to, root_url = get_next_url_for_login_page(request, include_host=True) redirect_url = get_redirect_url_with_host(root_url, redirect_to) - authenticated_user = {'username': user.username, 'user_id': user.id} + authenticated_user = {'username': user.username, 'full_name': user.profile.name, 'user_id': user.id} response = self._create_response( request, {'authenticated_user': authenticated_user}, status_code=200, redirect_url=redirect_url ) 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 20261ab9a9dc..3de71534f2a6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -2258,6 +2258,11 @@ def _assert_redirect_url(self, response, expected_redirect_url): @override_settings(LOGIN_REDIRECT_WHITELIST=['openedx.service']) @skip_unless_lms def test_register_success_with_redirect(self, next_url, course_id, expected_redirect): + expected_response = { + 'username': self.USERNAME, + 'full_name': self.NAME, + 'user_id': 1 + } post_params = { "email": self.EMAIL, "name": self.NAME, @@ -2282,7 +2287,7 @@ def test_register_success_with_redirect(self, next_url, course_id, expected_redi # Check that authenticated user details are also returned in # the response for successful registration decoded_response = json.loads(response.content.decode('utf-8')) - assert decoded_response['authenticated_user'] == {'username': self.USERNAME, 'user_id': 1} + assert decoded_response['authenticated_user'] == expected_response @mock.patch('openedx.core.djangoapps.user_authn.views.register._record_is_marketable_attribute') def test_logs_for_error_when_setting_is_marketable_attribute(self, set_is_marketable_attr): diff --git a/openedx/core/djangoapps/waffle_utils/views.py b/openedx/core/djangoapps/waffle_utils/views.py index 2a26430d183a..a630976210f9 100644 --- a/openedx/core/djangoapps/waffle_utils/views.py +++ b/openedx/core/djangoapps/waffle_utils/views.py @@ -4,11 +4,9 @@ from collections import OrderedDict from enum import Enum -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.permissions import IsStaff from edx_toggles.toggles.state import ToggleStateReport, get_or_create_toggle_response from rest_framework import views -from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response from .models import WaffleFlagCourseOverrideModel, WaffleFlagOrgOverrideModel @@ -59,10 +57,6 @@ class ToggleStateView(views.APIView): An endpoint for displaying the state of toggles in edx-platform. """ - authentication_classes = ( - JwtAuthentication, - SessionAuthentication, - ) permission_classes = (IsStaff,) def get(self, request): diff --git a/openedx/core/lib/teams_config.py b/openedx/core/lib/teams_config.py index 636c5073f825..48b0eafefcc1 100644 --- a/openedx/core/lib/teams_config.py +++ b/openedx/core/lib/teams_config.py @@ -304,6 +304,7 @@ class TeamsetType(Enum): open = "open" public_managed = "public_managed" private_managed = "private_managed" + open_managed = "open_managed" @classmethod def get_default(cls): diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py index b91ca1594cd9..deb46b07cecb 100644 --- a/openedx/core/lib/xblock_serializer/block_serializer.py +++ b/openedx/core/lib/xblock_serializer/block_serializer.py @@ -7,6 +7,8 @@ from lxml import etree +from cms.lib.xblock.tagging.tagged_block_mixin import TaggedBlockMixin + from .data import StaticFile from . import utils @@ -113,6 +115,10 @@ def _serialize_html_block(self, block) -> etree.Element: if block.use_latex_compiler: olx_node.attrib["use_latex_compiler"] = "true" + # Serialize and add tag data if any + if isinstance(block, TaggedBlockMixin): + block.add_tags_to_node(olx_node) + # Escape any CDATA special chars escaped_block_data = block.data.replace("]]>", "]]>") olx_node.text = etree.CDATA("\n" + escaped_block_data + "\n") diff --git a/openedx/core/lib/xblock_serializer/test_api.py b/openedx/core/lib/xblock_serializer/test_api.py index c589b9a9e32f..19e48bd9eefe 100644 --- a/openedx/core/lib/xblock_serializer/test_api.py +++ b/openedx/core/lib/xblock_serializer/test_api.py @@ -6,8 +6,11 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.django import contentstore, modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, upload_file_to_course -from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME +from openedx_tagging.core.tagging.models import Tag +from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg +from openedx.core.djangoapps.content_tagging import api as tagging_api from . import api @@ -65,6 +68,112 @@ """ +EXPECTED_OPENASSESSMENT_OLX = """ + + Open Response Assessment + + + + + Replace this text with your own sample response for this assignment. Then, under Response Score to the right, select an option for each criterion. Learners practice performing peer assessments by assessing this response and comparing the options that they select in the rubric with the options that you specified. + + + + + + Replace this text with another sample response, and then specify the options that you would select for this response. + + + + + + + + + + + + Censorship in the Libraries + + 'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author + + Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. + + Read for conciseness, clarity of thought, and form. + + + + + + Ideas + + Determine if there is a unifying theme or main idea. + + + + + + Content + + Assess the content of the submission + + + + + + +(Optional) What aspects of this response stood out to you? What did it do well? How could it be improved? + + +I think that this response... + + + +""" + + @skip_unless_cms class XBlockSerializationTestCase(SharedModuleStoreTestCase): """ @@ -79,6 +188,25 @@ def setUpClass(cls): super().setUpClass() cls.course = ToyCourseFactory.create() + # Create taxonomies and tags for testing + cls.taxonomy1 = tagging_api.create_taxonomy(name="t1", enabled=True, export_id="t1-export-id") + TaxonomyOrg.objects.create( + taxonomy=cls.taxonomy1, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + cls.taxonomy2 = tagging_api.create_taxonomy(name="t2", enabled=True, export_id="t2-export-id") + TaxonomyOrg.objects.create( + taxonomy=cls.taxonomy2, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + root1 = Tag.objects.create(taxonomy=cls.taxonomy1, value="ROOT1") + root2 = Tag.objects.create(taxonomy=cls.taxonomy2, value="ROOT2") + Tag.objects.create(taxonomy=cls.taxonomy1, value="normal tag", parent=root1) + Tag.objects.create(taxonomy=cls.taxonomy1, value=" tag", parent=root1) + Tag.objects.create(taxonomy=cls.taxonomy1, value="anotherTag", parent=root1) + Tag.objects.create(taxonomy=cls.taxonomy2, value="tag", parent=root2) + Tag.objects.create(taxonomy=cls.taxonomy2, value="other tag", parent=root2) + def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool: """ Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """ self.assertEqual( @@ -287,3 +415,273 @@ def test_jsinput_extra_files(self): """ ) + + def test_tagged_units(self): + """ + Test units (vertical blocks) that have applied tags + """ + course = CourseFactory.create(display_name='Tagged Unit Course', run="TUC") + unit = BlockFactory( + parent_location=course.location, + category="vertical", + display_name="Tagged Unit", + ) + + # Add a bunch of tags + tagging_api.tag_object( + object_id=unit.location, + taxonomy=self.taxonomy1, + tags=["normal tag", " tag", "anotherTag"] + ) + tagging_api.tag_object( + object_id=unit.location, + taxonomy=self.taxonomy2, + tags=["tag", "other tag"] + ) + + # Check that the tags data in included in the OLX and properly escaped + serialized = api.serialize_xblock_to_olx(unit) + expected_serialized_tags = ( + "t1-export-id:%3Cspecial %22%27-%3D%2C. %7C%3D chars %3E tag,anotherTag,normal tag;" + "t2-export-id:other tag,tag" + ) + self.assertXmlEqual( + serialized.olx_str, + f""" + + """ + ) + + def test_tagged_html_block(self): + """ + Test html blocks that have applied tags + """ + course = CourseFactory.create(display_name='Tagged HTML Block Test Course', run="THBTC") + + # Create html block + html_block = BlockFactory.create( + parent_location=course.location, + category="html", + display_name="Tagged Non-default HTML Block", + editor="raw", + use_latex_compiler=True, + data="🍔", + ) + + # Add a bunch of tags + tagging_api.tag_object( + object_id=html_block.location, + taxonomy=self.taxonomy1, + tags=["normal tag", " tag", "anotherTag"] + ) + tagging_api.tag_object( + object_id=html_block.location, + taxonomy=self.taxonomy2, + tags=["tag", "other tag"] + ) + + # Check that the tags data in included in the OLX and properly escaped + serialized = api.serialize_xblock_to_olx(html_block) + expected_serialized_tags = ( + "t1-export-id:%3Cspecial %22%27-%3D%2C. %7C%3D chars %3E tag,anotherTag,normal tag;" + "t2-export-id:other tag,tag" + ) + self.assertXmlEqual( + serialized.olx_str, + f""" + + """ + ) + + def test_tagged_problem_blocks(self): + """ + Test regular problem block + problem block with dependancy that + have applied tags + """ + course = CourseFactory.create(display_name='Tagged Python Testing course', run="TPY") + upload_file_to_course( + course_key=course.id, + contentstore=contentstore(), + source_file='./common/test/data/uploads/python_lib.zip', + target_filename=DEFAULT_PYTHON_LIB_FILENAME, + ) + + regular_problem = BlockFactory.create( + parent_location=course.location, + category="problem", + display_name="Tagged Problem No Python", + max_attempts=3, + data="", + ) + + python_problem = BlockFactory.create( + parent_location=course.location, + category="problem", + display_name="Tagged Python Problem", + data='This uses python: ...', + ) + + # Add a bunch of tags to the problem blocks + tagging_api.tag_object( + object_id=regular_problem.location, + taxonomy=self.taxonomy1, + tags=["normal tag", " tag", "anotherTag"] + ) + tagging_api.tag_object( + object_id=regular_problem.location, + taxonomy=self.taxonomy2, + tags=["tag", "other tag"] + ) + tagging_api.tag_object( + object_id=python_problem.location, + taxonomy=self.taxonomy1, + tags=["normal tag", " tag", "anotherTag"] + ) + tagging_api.tag_object( + object_id=python_problem.location, + taxonomy=self.taxonomy2, + tags=["tag", "other tag"] + ) + + # Check that the tags data in included in the OLX and properly escaped + serialized = api.serialize_xblock_to_olx(regular_problem) + expected_serialized_tags = ( + "t1-export-id:%3Cspecial %22%27-%3D%2C. %7C%3D chars %3E tag,anotherTag,normal tag;" + "t2-export-id:other tag,tag" + ) + self.assertXmlEqual( + serialized.olx_str, + f""" + + + + """ + ) + + serialized = api.serialize_xblock_to_olx(python_problem) + expected_serialized_tags = ( + "t1-export-id:%3Cspecial %22%27-%3D%2C. %7C%3D chars %3E tag,anotherTag,normal tag;" + "t2-export-id:other tag,tag" + ) + self.assertXmlEqual( + serialized.olx_str, + f""" + + This uses python: ... + + """ + ) + + def test_tagged_library_content_blocks(self): + """ + Test library content blocks that have applied tags + """ + course = CourseFactory.create(display_name='Tagged Library Content course', run="TLCC") + lib = LibraryFactory() + lc_block = BlockFactory( + parent_location=course.location, + category="library_content", + source_library_id=str(lib.location.library_key), + display_name="Tagged LC Block", + max_count=1, + ) + + # Add a bunch of tags to the library content block + tagging_api.tag_object( + object_id=lc_block.location, + taxonomy=self.taxonomy1, + tags=["normal tag", " tag", "anotherTag"] + ) + + # Check that the tags data in included in the OLX and properly escaped + serialized = api.serialize_xblock_to_olx(lc_block) + self.assertXmlEqual( + serialized.olx_str, + f""" + + """ + ) + + def test_tagged_video_block(self): + """ + Test video blocks that have applied tags + """ + course = CourseFactory.create(display_name='Tagged Video Test course', run="TVTC") + video_block = BlockFactory.create( + parent_location=course.location, + category="video", + display_name="Tagged Video Block", + ) + + # Add tags to video block + tagging_api.tag_object( + object_id=video_block.location, + taxonomy=self.taxonomy1, + tags=["normal tag", " tag", "anotherTag"] + ) + + # Check that the tags data in included in the OLX and properly escaped + serialized = api.serialize_xblock_to_olx(video_block) + self.assertXmlEqual( + serialized.olx_str, + """ +