Skip to content

Commit a02eaa8

Browse files
Merge branch 'master' into feat/add-filter-instructor-tabs
2 parents fe196be + d327dd3 commit a02eaa8

40 files changed

Lines changed: 2540 additions & 249 deletions

File tree

cms/djangoapps/contentstore/views/block.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.views.decorators.clickjacking import xframe_options_exempt
1313
from django.views.decorators.http import require_http_methods
1414
from opaque_keys.edx.keys import CourseKey
15+
from openedx_authz.constants.permissions import COURSES_VIEW_COURSE
1516
from web_fragments.fragment import Fragment
1617

1718
from cms.djangoapps.contentstore.utils import load_services_for_studio
@@ -27,6 +28,8 @@
2728
from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string
2829
from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access
2930
from common.djangoapps.util.json_request import JsonResponse, expect_json
31+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
32+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
3033
from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled
3134
from openedx.core.lib.xblock_utils import hash_resource, request_token, wrap_xblock, wrap_xblock_aside
3235
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
@@ -329,7 +332,12 @@ def xblock_outline_handler(request, usage_key_string):
329332
a course.
330333
"""
331334
usage_key = usage_key_with_run(usage_key_string)
332-
if not has_studio_read_access(request.user, usage_key.course_key):
335+
if not user_has_course_permission(
336+
request.user,
337+
COURSES_VIEW_COURSE.identifier,
338+
usage_key.course_key,
339+
LegacyAuthoringPermission.READ,
340+
):
333341
raise PermissionDenied()
334342

335343
response_format = request.GET.get("format", "html")

cms/djangoapps/contentstore/views/tests/test_block.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from opaque_keys.edx.asides import AsideUsageKeyV2
2020
from opaque_keys.edx.keys import CourseKey, UsageKey
2121
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
22+
from openedx_authz.constants.roles import COURSE_ADMIN, COURSE_AUDITOR, COURSE_EDITOR, COURSE_STAFF
2223
from openedx_events.content_authoring.data import DuplicatedXBlockData
2324
from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED
2425
from openedx_events.testing import OpenEdxEventsTestMixin
@@ -54,6 +55,7 @@
5455
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
5556
from common.test.utils import assert_dict_contains_subset
5657
from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION
58+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
5759
from openedx.core.djangoapps.content_tagging import api as tagging_api
5860
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
5961
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
@@ -3486,6 +3488,145 @@ def validate_xblock_info_consistency(
34863488
self.assertIsNone(xblock_info.get("child_info", None)) # noqa: PT009
34873489

34883490

3491+
@ddt.ddt
3492+
class TestXBlockOutlineHandlerAuthz(CourseAuthoringAuthzTestMixin, ItemTest):
3493+
"""
3494+
Unit tests for xblock_outline_handler authorization functionality.
3495+
"""
3496+
3497+
def setUp(self):
3498+
super().setUp()
3499+
user_id = self.user.id
3500+
self.chapter = BlockFactory.create(
3501+
parent_location=self.course.location,
3502+
category="chapter",
3503+
display_name="Week 1",
3504+
user_id=user_id,
3505+
)
3506+
self.sequential = BlockFactory.create(
3507+
parent_location=self.chapter.location,
3508+
category="sequential",
3509+
display_name="Lesson 1",
3510+
user_id=user_id,
3511+
)
3512+
self.vertical = BlockFactory.create(
3513+
parent_location=self.sequential.location,
3514+
category="vertical",
3515+
display_name="Unit 1",
3516+
user_id=user_id,
3517+
)
3518+
# Assign COURSE_STAFF role to authorized_user for the course
3519+
self.add_user_to_role_in_course(
3520+
self.authorized_user,
3521+
COURSE_STAFF.external_key,
3522+
self.course.id
3523+
)
3524+
3525+
def test_authorized_user_gets_json_response(self):
3526+
"""
3527+
Test that authorized user gets JSON response from xblock_outline_handler.
3528+
"""
3529+
outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key)
3530+
3531+
self.client.login(username=self.authorized_user.username, password=self.password)
3532+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3533+
3534+
assert resp.status_code == 200
3535+
json_response = json.loads(resp.content.decode("utf-8"))
3536+
assert "id" in json_response
3537+
assert "display_name" in json_response
3538+
assert "child_info" in json_response
3539+
3540+
@ddt.data(
3541+
COURSE_ADMIN.external_key,
3542+
COURSE_AUDITOR.external_key,
3543+
COURSE_EDITOR.external_key,
3544+
)
3545+
def test_other_course_roles_can_view_outline(self, role_key):
3546+
"""
3547+
Test that course_admin, course_auditor, and course_editor roles
3548+
can access the outline (all have COURSES_VIEW_COURSE).
3549+
"""
3550+
role_user = UserFactory(password=self.password)
3551+
self.add_user_to_role_in_course(role_user, role_key, self.course.id)
3552+
3553+
outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key)
3554+
self.client.login(username=role_user.username, password=self.password)
3555+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3556+
3557+
assert resp.status_code == 200
3558+
3559+
def test_unauthorized_user_gets_permission_denied(self):
3560+
"""
3561+
Test that unauthorized user gets 403 response from xblock_outline_handler.
3562+
"""
3563+
outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key)
3564+
3565+
self.client.login(username=self.unauthorized_user.username, password=self.password)
3566+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3567+
3568+
assert resp.status_code == 403
3569+
3570+
def test_superuser_gets_json_response(self):
3571+
"""
3572+
Test that superuser gets JSON response from xblock_outline_handler.
3573+
"""
3574+
outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key)
3575+
3576+
self.client.login(username=self.super_user.username, password=self.password)
3577+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3578+
3579+
assert resp.status_code == 200
3580+
json_response = json.loads(resp.content.decode("utf-8"))
3581+
assert "id" in json_response
3582+
assert "display_name" in json_response
3583+
assert "child_info" in json_response
3584+
3585+
def test_staff_user_gets_json_response(self):
3586+
"""
3587+
Test that staff user gets JSON response from xblock_outline_handler.
3588+
"""
3589+
outline_url = reverse_usage_url("xblock_outline_handler", self.usage_key)
3590+
3591+
self.client.login(username=self.staff_user.username, password=self.password)
3592+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3593+
3594+
assert resp.status_code == 200
3595+
json_response = json.loads(resp.content.decode("utf-8"))
3596+
assert "id" in json_response
3597+
assert "display_name" in json_response
3598+
assert "child_info" in json_response
3599+
3600+
def test_authorized_chapter_outline(self):
3601+
"""
3602+
Test that authorized user can access chapter-level outline.
3603+
"""
3604+
outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location)
3605+
3606+
self.client.login(username=self.authorized_user.username, password=self.password)
3607+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3608+
3609+
assert resp.status_code == 200
3610+
json_response = json.loads(resp.content.decode("utf-8"))
3611+
assert json_response["display_name"] == "Week 1"
3612+
assert "child_info" in json_response
3613+
# Verify that children are included (should have the sequential)
3614+
children = json_response["child_info"]["children"]
3615+
assert len(children) > 0
3616+
assert children[0]["display_name"] == "Lesson 1"
3617+
3618+
def test_unauthorized_chapter_outline(self):
3619+
"""
3620+
Test that unauthorized user cannot access chapter-level outline.
3621+
"""
3622+
outline_url = reverse_usage_url("xblock_outline_handler", self.chapter.location)
3623+
3624+
self.client.login(username=self.unauthorized_user.username, password=self.password)
3625+
resp = self.client.get(outline_url, HTTP_ACCEPT="application/json")
3626+
3627+
assert resp.status_code == 403
3628+
3629+
34893630
class TestGetMetadataWithProblemDefaults(ModuleStoreTestCase):
34903631
"""
34913632
Unit tests for _get_metadata_with_problem_defaults.

common/djangoapps/third_party_auth/pipeline.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ def B(*args, **kwargs):
102102
from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies
103103
from openedx.core.djangoapps.user_authn.toggles import is_auto_generated_username_enabled
104104
from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect
105-
from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username
106105

107106
from . import provider
108107

@@ -1010,6 +1009,8 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs): # lin
10101009
slug_func = lambda val: val
10111010

10121011
if is_auto_generated_username_enabled() and details.get('username') is None:
1012+
# Lazy import to avoid circular dependency
1013+
from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username
10131014
username = get_auto_generated_username(details)
10141015
else:
10151016
if email_as_username and details.get('email'):

docs/concepts/extension_points.rst

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,16 @@ Here are the different integration points that python plugins can use:
119119
- The course home page (the landing page for the course) includes a "Course Tools" section that provides links to "tools" associated with the course. Examples of course tool plugins included in the core are reviews, updates, and bookmarks. See |course_tools.py|_ to learn more.
120120

121121
This API may be changing soon with the new Courseware microfrontend implementation.
122-
* - Custom registration form app (``REGISTRATION_EXTENSION_FORM`` Django setting in the LMS)
122+
* - Custom profile extension form app (``PROFILE_EXTENSION_FORM`` Django setting in the LMS)
123123
- Trial, Stable
124-
- By default, the registration page for each instance of Open edX has fields that ask for information such as a user’s name, country, and highest level of education completed. You can add custom fields to the registration page for your own Open edX instance. These fields can be different types, including text entry fields and drop-down lists. See `Adding Custom Fields to the Registration Page`_.
124+
- By default, the registration page for each instance of Open edX has fields that ask for information such as a user’s name, country, and highest level of education completed. You can add custom fields to the registration page and user profile for your own Open edX instance. These fields can be different types, including text entry fields and drop-down lists. See `Adding Custom Fields to the Registration Page`_.
125+
126+
**Important Migration Note:**
127+
128+
- ``REGISTRATION_EXTENSION_FORM`` (deprecated) continues to work with old behavior: custom fields only for registration, data stored in UserProfile.meta
129+
- ``PROFILE_EXTENSION_FORM`` (new) enables new capabilities: custom fields in registration and account settings, data stored in dedicated model
130+
131+
Sites using the deprecated setting will maintain backward compatibility. To get the new capabilities, migrate to ``PROFILE_EXTENSION_FORM``.
125132
* - Learning Context (``openedx.learning_context``)
126133
- Trial, Limited
127134
- A "Learning Context" is a course, a library, a program, a blog, an external site, or some other collection of content where learning happens. If you are trying to build a totally new learning experience that's not a type of course, you may need to implement a new learning context. Learning contexts are a new abstraction and are only supported in the nascent openedx_content-based XBlock runtime. Since existing courses use modulestore instead of openedx_content, they are not yet implemented as learning contexts. However, openedx_content-based content libraries are. See |learning_context.py|_ to learn more.

lms/djangoapps/instructor/access.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@
5353
FORUM_ROLE_COMMUNITY_TA,
5454
)
5555

56+
INSTRUCTOR_DASHBOARD_ROLE_SORT_ORDER = (
57+
'staff', 'limited_staff', 'instructor', 'beta', 'data_researcher',
58+
*FORUM_ROLES, 'ccx_coach',
59+
)
60+
5661
ROLE_DISPLAY_NAMES = {
5762
'instructor': _('Admin'),
5863
'staff': _('Staff'),

lms/djangoapps/instructor/tests/test_api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4581,6 +4581,24 @@ def test_change_due_date_v2_success(self):
45814581

45824582
assert get_extended_due(self.course, self.homework, self.user1) == due_date
45834583

4584+
def test_change_due_date_v2_without_reason(self):
4585+
"""Test that reason is optional — both omitted and blank are accepted."""
4586+
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})
4587+
base_payload = {
4588+
'email_or_username': self.user1.username,
4589+
'block_id': str(self.homework.location),
4590+
'due_datetime': '12/30/2013 00:00',
4591+
}
4592+
# Omitted reason
4593+
response = self.client.post(url, json.dumps(base_payload), content_type='application/json')
4594+
assert response.status_code == 200, response.content
4595+
4596+
# Blank reason
4597+
response = self.client.post(
4598+
url, json.dumps({**base_payload, 'reason': ''}), content_type='application/json'
4599+
)
4600+
assert response.status_code == 200, response.content
4601+
45844602
def test_change_due_date_v2_with_email(self):
45854603
"""Test due date change using email instead of username"""
45864604
url = reverse('instructor_api_v2:change_due_date', kwargs={'course_id': str(self.course.id)})

lms/djangoapps/instructor/tests/test_api_v2.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,14 +119,22 @@ def _get_url(self, course_id=None):
119119
course_id = str(self.course_key)
120120
return reverse('instructor_api_v2:course_metadata', kwargs={'course_id': course_id})
121121

122-
@override_settings(COURSE_AUTHORING_MICROFRONTEND_URL='http://localhost:2001/authoring')
123-
@override_settings(ADMIN_CONSOLE_MICROFRONTEND_URL='http://localhost:2025/admin-console')
122+
@override_settings(
123+
COURSE_AUTHORING_MICROFRONTEND_URL='http://localhost:2001/authoring',
124+
ADMIN_CONSOLE_MICROFRONTEND_URL='http://localhost:2025/admin-console',
125+
# intentionally include trailing slash to test URL joining logic
126+
WRITABLE_GRADEBOOK_URL='http://localhost:1994/gradebook/',
127+
)
124128
def test_get_course_metadata_as_instructor(self):
125129
"""
126130
Test that an instructor can retrieve comprehensive course metadata.
127131
"""
128-
self.client.force_authenticate(user=self.instructor)
129-
response = self.client.get(self._get_url())
132+
with patch(
133+
'lms.djangoapps.instructor.views.serializers_v2.is_writable_gradebook_enabled',
134+
return_value=True,
135+
):
136+
self.client.force_authenticate(user=self.instructor)
137+
response = self.client.get(self._get_url())
130138

131139
assert response.status_code == status.HTTP_200_OK
132140
data = response.data
@@ -176,9 +184,14 @@ def test_get_course_metadata_as_instructor(self):
176184
assert 'analytics_dashboard_message' in data
177185
assert 'studio_grading_url' in data
178186
assert 'admin_console_url' in data
187+
assert 'gradebook_url' in data
188+
189+
# Verify current user's username is returned
190+
assert data['username'] == self.instructor.username
179191

180192
assert data['studio_grading_url'] == f'http://localhost:2001/authoring/course/{self.course.id}/settings/grading'
181193
assert data['admin_console_url'] == 'http://localhost:2025/admin-console/authz'
194+
assert data['gradebook_url'] == f'http://localhost:1994/gradebook/{self.course.id}'
182195

183196
@override_settings(ADMIN_CONSOLE_MICROFRONTEND_URL='http://localhost:2025/admin-console')
184197
def test_admin_console_url_requires_instructor_access(self):
@@ -214,12 +227,13 @@ def test_get_course_metadata_as_staff(self):
214227
self.client.force_authenticate(user=self.staff)
215228
response = self.client.get(self._get_url())
216229

217-
self.assertEqual(response.status_code, status.HTTP_200_OK) # noqa: PT009
230+
assert response.status_code == status.HTTP_200_OK
218231
data = response.data
219-
self.assertEqual(data['course_id'], str(self.course_key)) # noqa: PT009
220-
self.assertIn('permissions', data) # noqa: PT009
232+
assert data['course_id'] == str(self.course_key)
233+
assert 'permissions' in data
221234
# Staff should have staff permission
222-
self.assertTrue(data['permissions']['staff']) # noqa: PT009
235+
assert data['permissions']['staff'] is True
236+
assert data['username'] == self.staff.username
223237

224238
def test_get_course_metadata_unauthorized(self):
225239
"""
@@ -2885,6 +2899,29 @@ def test_list_roles_with_ccx_enabled(self):
28852899
ccx_entry = next(r for r in response.data['results'] if r['role'] == 'ccx_coach')
28862900
assert ccx_entry['display_name'] == 'CCX Coach'
28872901

2902+
@override_settings(FEATURES={**settings.FEATURES, 'CUSTOM_COURSES_EDX': True})
2903+
def test_roles_sort_order(self):
2904+
"""Roles are returned in the expected display order, with ccx_coach last."""
2905+
ccx_course = CourseFactory.create(
2906+
org='edX',
2907+
number='SortX',
2908+
run='2024',
2909+
display_name='Sort Order Test Course',
2910+
enable_ccx=True,
2911+
)
2912+
url = reverse('instructor_api_v2:course_team_roles', kwargs={'course_id': str(ccx_course.id)})
2913+
instructor = InstructorFactory.create(course_key=ccx_course.id)
2914+
self.client.force_authenticate(user=instructor)
2915+
response = self.client.get(url)
2916+
2917+
assert response.status_code == status.HTTP_200_OK
2918+
returned_roles = [r['role'] for r in response.data['results']]
2919+
assert returned_roles == [
2920+
'staff', 'limited_staff', 'instructor', 'beta', 'data_researcher',
2921+
'Administrator', 'Moderator', 'Group Moderator', 'Community TA',
2922+
'ccx_coach',
2923+
]
2924+
28882925
def test_list_roles_unauthenticated(self):
28892926
"""Unauthenticated request returns 401."""
28902927
response = self.client.get(self.url)

0 commit comments

Comments
 (0)