diff --git a/cms/envs/common.py b/cms/envs/common.py index 0aeef80361f9..3c9ada1bb4e3 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -256,9 +256,6 @@ MARKETING_EMAILS_OPT_IN = False -############################# MICROFRONTENDS ################################### -COURSE_AUTHORING_MICROFRONTEND_URL = None - ############################# SET PATH INFORMATION ############################# PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/cms CMS_ROOT = REPO_ROOT / "cms" # noqa: F405 diff --git a/lms/djangoapps/instructor/tests/test_api_v2.py b/lms/djangoapps/instructor/tests/test_api_v2.py index b8c2baba8a3a..4a7853671caf 100644 --- a/lms/djangoapps/instructor/tests/test_api_v2.py +++ b/lms/djangoapps/instructor/tests/test_api_v2.py @@ -81,6 +81,7 @@ def setUp(self): self.admin = AdminFactory.create() self.instructor = InstructorFactory.create(course_key=self.course_key) self.staff = StaffFactory.create(course_key=self.course_key) + self.django_staff_user = UserFactory.create(is_staff=True) self.data_researcher = UserFactory.create() CourseDataResearcherRole(self.course_key).add_users(self.data_researcher) CourseInstructorRole(self.proctored_course.id).add_users(self.instructor) @@ -118,6 +119,8 @@ def _get_url(self, course_id=None): course_id = str(self.course_key) return reverse('instructor_api_v2:course_metadata', kwargs={'course_id': course_id}) + @override_settings(COURSE_AUTHORING_MICROFRONTEND_URL='http://localhost:2001/authoring') + @override_settings(ADMIN_CONSOLE_MICROFRONTEND_URL='http://localhost:2025/admin-console') def test_get_course_metadata_as_instructor(self): """ Test that an instructor can retrieve comprehensive course metadata. @@ -125,53 +128,84 @@ def test_get_course_metadata_as_instructor(self): self.client.force_authenticate(user=self.instructor) response = self.client.get(self._get_url()) - self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009 + assert response.status_code == status.HTTP_200_OK data = response.data # Verify basic course information - self.assertEqual(data['course_id'], str(self.course_key)) # noqa: PT009 - self.assertEqual(data['display_name'], 'Demonstration Course') # noqa: PT009 - self.assertEqual(data['org'], 'edX') # noqa: PT009 - self.assertEqual(data['course_number'], 'DemoX') # noqa: PT009 - self.assertEqual(data['course_run'], 'Demo_Course') # noqa: PT009 - self.assertEqual(data['pacing'], 'instructor') # noqa: PT009 + assert data['course_id'] == str(self.course_key) + assert data['display_name'] == 'Demonstration Course' + assert data['org'] == 'edX' + assert data['course_number'] == 'DemoX' + assert data['course_run'] == 'Demo_Course' + assert data['pacing'] == 'instructor' # Verify enrollment counts structure - self.assertIn('enrollment_counts', data) # noqa: PT009 - self.assertIn('total', data['enrollment_counts']) # noqa: PT009 - self.assertIn('total_enrollment', data) # noqa: PT009 - self.assertGreaterEqual(data['total_enrollment'], 3) # noqa: PT009 + assert 'enrollment_counts' in data + assert 'total' in data['enrollment_counts'] + assert 'total_enrollment' in data + assert data['total_enrollment'] >= 3 # Verify role-based enrollment counts are present - self.assertIn('learner_count', data) # noqa: PT009 - self.assertIn('staff_count', data) # noqa: PT009 - self.assertEqual(data['total_enrollment'], data['learner_count'] + data['staff_count']) # noqa: PT009 + assert 'learner_count' in data + assert 'staff_count' in data + assert data['total_enrollment'] == data['learner_count'] + data['staff_count'] # Verify permissions structure - self.assertIn('permissions', data) # noqa: PT009 + assert 'permissions' in data permissions_data = data['permissions'] - self.assertIn('admin', permissions_data) # noqa: PT009 - self.assertIn('instructor', permissions_data) # noqa: PT009 - self.assertIn('staff', permissions_data) # noqa: PT009 - self.assertIn('forum_admin', permissions_data) # noqa: PT009 - self.assertIn('finance_admin', permissions_data) # noqa: PT009 - self.assertIn('sales_admin', permissions_data) # noqa: PT009 - self.assertIn('data_researcher', permissions_data) # noqa: PT009 + assert 'admin' in permissions_data + assert 'instructor' in permissions_data + assert 'staff' in permissions_data + assert 'forum_admin' in permissions_data + assert 'finance_admin' in permissions_data + assert 'sales_admin' in permissions_data + assert 'data_researcher' in permissions_data # Verify sections structure - self.assertIn('tabs', data) # noqa: PT009 - self.assertIsInstance(data['tabs'], list) # noqa: PT009 + assert 'tabs' in data + assert isinstance(data['tabs'], list) # Verify other metadata fields - self.assertIn('num_sections', data) # noqa: PT009 - self.assertIn('tabs', data) # noqa: PT009 - self.assertIn('grade_cutoffs', data) # noqa: PT009 - self.assertIn('course_errors', data) # noqa: PT009 - self.assertIn('studio_url', data) # noqa: PT009 - self.assertIn('disable_buttons', data) # noqa: PT009 - self.assertIn('has_started', data) # noqa: PT009 - self.assertIn('has_ended', data) # noqa: PT009 - self.assertIn('analytics_dashboard_message', data) # noqa: PT009 + assert 'num_sections' in data + assert 'grade_cutoffs' in data + assert 'course_errors' in data + assert 'studio_url' in data + assert 'disable_buttons' in data + assert 'has_started' in data + assert 'has_ended' in data + assert 'analytics_dashboard_message' in data + assert 'studio_grading_url' in data + assert 'admin_console_url' in data + + assert data['studio_grading_url'] == f'http://localhost:2001/authoring/course/{self.course.id}/settings/grading' + assert data['admin_console_url'] == 'http://localhost:2025/admin-console/authz' + + @override_settings(ADMIN_CONSOLE_MICROFRONTEND_URL='http://localhost:2025/admin-console') + def test_admin_console_url_requires_instructor_access(self): + """ + Test that the admin console URL is only available to users with instructor access. + """ + # data researcher has access to course but is not an instructor + self.client.force_authenticate(user=self.data_researcher) + response = self.client.get(self._get_url()) + + assert response.status_code == status.HTTP_200_OK + assert 'admin_console_url' in response.data + data = response.data + assert data['admin_console_url'] is None + + @override_settings(ADMIN_CONSOLE_MICROFRONTEND_URL='http://localhost:2025/admin-console') + def test_django_staff_user_without_instructor_access_can_see_admin_console_url(self): + """ + Test that Django staff users without instructor access can see the admin console URL. + """ + self.client.force_authenticate(user=self.django_staff_user) + response = self.client.get(self._get_url()) + + assert response.status_code == status.HTTP_200_OK + assert 'admin_console_url' in response.data + data = response.data + assert data['admin_console_url'] == 'http://localhost:2025/admin-console/authz' def test_get_course_metadata_as_staff(self): """ diff --git a/lms/djangoapps/instructor/views/api_v2.py b/lms/djangoapps/instructor/views/api_v2.py index f0f164cc077b..e37e144495dc 100644 --- a/lms/djangoapps/instructor/views/api_v2.py +++ b/lms/djangoapps/instructor/views/api_v2.py @@ -234,6 +234,8 @@ def get(self, request, course_id): "grade_cutoffs": "A is 0.9, B is 0.8, C is 0.7, D is 0.6", "course_errors": [], "studio_url": "https://studio.example.com/course/course-v1:edX+DemoX+2024", + # May be null if user does not have access: + "admin_console_url": "http://apps.local.openedx.io:2025/admin-console/authz", "permissions": { "admin": false, "instructor": true, diff --git a/lms/djangoapps/instructor/views/serializers_v2.py b/lms/djangoapps/instructor/views/serializers_v2.py index fdb7e716434b..89fa151716c5 100644 --- a/lms/djangoapps/instructor/views/serializers_v2.py +++ b/lms/djangoapps/instructor/views/serializers_v2.py @@ -34,6 +34,7 @@ from lms.djangoapps.instructor.access import FORUM_ROLES, ROLES from lms.djangoapps.instructor.views.instructor_dashboard import get_analytics_dashboard_message from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from xmodule.modulestore.django import modulestore from .tools import DashboardError, get_student_from_identifier, parse_datetime @@ -77,6 +78,9 @@ class CourseInformationSerializerV2(serializers.Serializer): studio_grading_url = serializers.SerializerMethodField( help_text="URL to the Studio grading settings page for the course (null if not configured)" ) + admin_console_url = serializers.SerializerMethodField( + help_text="URL to the admin console (requires instructor access and MFE configuration, null if not accessible)" + ) permissions = serializers.SerializerMethodField(help_text="User permissions for instructor dashboard features") tabs = serializers.SerializerMethodField(help_text="List of course tabs with configuration and display information") disable_buttons = serializers.SerializerMethodField( @@ -462,10 +466,27 @@ def get_gradebook_url(self, data): def get_studio_grading_url(self, data): """Get Studio MFE grading settings URL for the course.""" course_key = data['course'].id - mfe_base_url = getattr(settings, 'COURSE_AUTHORING_MICROFRONTEND_URL', None) - if mfe_base_url: - return f'{mfe_base_url}/course/{course_key}/settings/grading' - return None + mfe_base_url = configuration_helpers.get_value( + 'COURSE_AUTHORING_MICROFRONTEND_URL', + getattr(settings, 'COURSE_AUTHORING_MICROFRONTEND_URL', None) + ) + if not mfe_base_url: + return None + return f'{mfe_base_url}/course/{course_key}/settings/grading' + + def get_admin_console_url(self, data): + """Get admin console URL (requires instructor access and MFE configuration, null if not accessible).""" + request = data['request'] + has_instructor_access = has_access(request.user, 'instructor', data['course']) + mfe_base_url = configuration_helpers.get_value( + 'ADMIN_CONSOLE_MICROFRONTEND_URL', + getattr(settings, 'ADMIN_CONSOLE_MICROFRONTEND_URL', None) + ) + + has_permissions = request.user.is_staff or has_instructor_access + if not mfe_base_url or not has_permissions: + return None + return f'{mfe_base_url}/authz' def get_disable_buttons(self, data): """Check if buttons should be disabled for large courses.""" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 970fa898ce2f..f7584c707331 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -400,6 +400,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing EXAMS_DASHBOARD_MICROFRONTEND_URL = 'http://localhost:2020' INSTRUCTOR_MICROFRONTEND_URL = 'http://localhost:2003/instructor-dashboard' CATALOG_MICROFRONTEND_URL = 'http://localhost:1998/catalog' +COURSE_AUTHORING_MICROFRONTEND_URL = 'http://localhost:2001/authoring' ################### FRONTEND APPLICATION DISCUSSIONS ################### DISCUSSIONS_MICROFRONTEND_URL = 'http://localhost:2002' diff --git a/openedx/envs/common.py b/openedx/envs/common.py index 6a08ff1d762b..a2a004385fca 100644 --- a/openedx/envs/common.py +++ b/openedx/envs/common.py @@ -1905,6 +1905,20 @@ def add_optional_apps(optional_apps, installed_apps): # .. setting_description: Base URL of the micro-frontend-based learner home page. LEARNER_HOME_MICROFRONTEND_URL = None +# .. setting_name: COURSE_AUTHORING_MICROFRONTEND_URL +# .. setting_default: None +# .. setting_description: Base URL of the micro-frontend-based course authoring (Studio) page. +# Used by both LMS and CMS backend code to construct links into the authoring MFE. +COURSE_AUTHORING_MICROFRONTEND_URL = None + +# .. setting_name: ADMIN_CONSOLE_MICROFRONTEND_URL +# .. setting_default: None +# .. setting_description: Base URL of the micro-frontend-based admin console page. +# Used by LMS backend code to construct links into the admin console MFE. +# Although the admin console is CMS-centric, this setting lives in the shared +# base so both LMS and CMS can reference it without duplication. +ADMIN_CONSOLE_MICROFRONTEND_URL = None + ################################## Swift ################################### SWIFT_USERNAME = None