Skip to content

Commit e6e450c

Browse files
authored
Merge branch 'master' into marslan/10256-course_detail_title
2 parents 449f4ee + 18ca8c4 commit e6e450c

101 files changed

Lines changed: 2927 additions & 1818 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cms/djangoapps/contentstore/api/tests/base.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ class BaseCourseViewTest(SharedModuleStoreTestCase, APITestCase):
1818
Base test class for course data views.
1919
"""
2020
view_name = None # The name of the view to use in reverse() call in self.get_url()
21+
course_key_arg_name = 'course_id'
22+
extra_request_args = {}
2123

2224
@classmethod
2325
def setUpClass(cls):
@@ -86,9 +88,10 @@ def get_url(self, course_id):
8688
"""
8789
Helper function to create the url
8890
"""
91+
args = {
92+
self.course_key_arg_name: course_id,
93+
}
8994
return reverse(
9095
self.view_name,
91-
kwargs={
92-
'course_id': course_id
93-
}
96+
kwargs= args | self.extra_request_args
9497
)

cms/djangoapps/contentstore/asset_storage_handlers.py

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,23 @@
1717
from django.views.decorators.csrf import ensure_csrf_cookie
1818
from django.views.decorators.http import require_http_methods, require_POST
1919
from opaque_keys.edx.keys import AssetKey, CourseKey
20+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
21+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
2022
from pymongo import ASCENDING, DESCENDING
2123

22-
from common.djangoapps.student.auth import has_course_author_access
2324
from common.djangoapps.util.date_utils import get_default_time_display
2425
from common.djangoapps.util.json_request import JsonResponse
2526
from openedx.core.djangoapps.contentserver.caching import del_cached_content
2627
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
2728
from openedx.core.djangoapps.user_api.models import UserPreference
29+
from openedx_authz.constants.permissions import (
30+
COURSES_VIEW_FILES,
31+
COURSES_CREATE_FILES,
32+
COURSES_DELETE_FILES,
33+
COURSES_EDIT_FILES,
34+
)
2835
from openedx_filters.content_authoring.filters import LMSPageURLRequested
36+
from openedx.core.toggles import enable_authz_course_authoring
2937
from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order
3038
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
3139
from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order
@@ -73,8 +81,8 @@ def handle_assets(request, course_key_string=None, asset_key_string=None):
7381
json: delete an asset
7482
'''
7583
course_key = CourseKey.from_string(course_key_string)
76-
if not has_course_author_access(request.user, course_key):
77-
raise PermissionDenied()
84+
# Enforce file permissions.
85+
_authz_enforce_file_permissions(request, course_key)
7886

7987
response_format = get_response_format(request)
8088
if request_response_format_is_json(request, response_format):
@@ -91,12 +99,60 @@ def handle_assets(request, course_key_string=None, asset_key_string=None):
9199
return HttpResponseNotFound()
92100

93101

102+
def _authz_enforce_file_permissions(request, course_key):
103+
"""
104+
Enforce permissions for file operations in asset handler.
105+
When the authz.enable_course_authoring flag is enabled for the specified course,
106+
This function enforces the appropriate file permission depending on request content.
107+
When the flag is disabled, it enforces the legacy has_studio_write_access permission.
108+
"""
109+
# Enforce permission to view files.
110+
# This is the minimum permission needed for handling assets.
111+
if not user_has_course_permission(
112+
request.user,
113+
COURSES_VIEW_FILES.identifier,
114+
course_key,
115+
LegacyAuthoringPermission.WRITE
116+
):
117+
raise PermissionDenied()
118+
119+
if enable_authz_course_authoring(course_key):
120+
# Check create, edit and delete permissions for AuthZ-enabled courses.
121+
if request.method in ('PUT', 'POST'):
122+
permission = (
123+
COURSES_CREATE_FILES.identifier
124+
if 'file' in request.FILES
125+
else COURSES_EDIT_FILES.identifier
126+
)
127+
128+
if not user_has_course_permission(
129+
request.user,
130+
permission,
131+
course_key,
132+
LegacyAuthoringPermission.WRITE
133+
):
134+
raise PermissionDenied()
135+
136+
if request.method == 'DELETE' and not user_has_course_permission(
137+
request.user,
138+
COURSES_DELETE_FILES.identifier,
139+
course_key,
140+
LegacyAuthoringPermission.WRITE
141+
):
142+
raise PermissionDenied()
143+
144+
94145
def get_asset_usage_path_json(request, course_key, asset_key_string):
95146
"""
96147
Get a list of units with ancestors that use given asset.
97148
"""
98149
course_key = CourseKey.from_string(course_key)
99-
if not has_course_author_access(request.user, course_key):
150+
if not user_has_course_permission(
151+
request.user,
152+
COURSES_VIEW_FILES.identifier,
153+
course_key,
154+
LegacyAuthoringPermission.WRITE
155+
):
100156
raise PermissionDenied()
101157
asset_location = AssetKey.from_string(asset_key_string) if asset_key_string else None
102158
usage_locations = _get_asset_usage_path(course_key, [{'asset_key': asset_location}])

cms/djangoapps/contentstore/rest_api/v1/views/certificates.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
from rest_framework.response import Response
77
from rest_framework.views import APIView
88

9+
from openedx_authz.constants.permissions import COURSES_MANAGE_CERTIFICATES
10+
911
from cms.djangoapps.contentstore.utils import get_certificates_context
1012
from cms.djangoapps.contentstore.rest_api.v1.serializers import (
1113
CourseCertificatesSerializer,
1214
)
13-
from common.djangoapps.student.auth import has_studio_write_access
15+
from openedx.core.djangoapps.authz.constants import LegacyAuthoringPermission
16+
from openedx.core.djangoapps.authz.decorators import user_has_course_permission
1417
from openedx.core.lib.api.view_utils import (
1518
DeveloperErrorViewMixin,
1619
verify_course_exists,
@@ -96,7 +99,12 @@ def get(self, request: Request, course_id: str):
9699
course_key = CourseKey.from_string(course_id)
97100
store = modulestore()
98101

99-
if not has_studio_write_access(request.user, course_key):
102+
if not user_has_course_permission(
103+
request.user,
104+
COURSES_MANAGE_CERTIFICATES.identifier,
105+
course_key,
106+
LegacyAuthoringPermission.WRITE
107+
):
100108
self.permission_denied(request)
101109

102110
with store.bulk_operations(course_key):

cms/djangoapps/contentstore/rest_api/v1/views/tests/test_certificates.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@
44
from django.urls import reverse
55
from rest_framework import status
66

7+
from openedx_authz.constants.roles import COURSE_STAFF, COURSE_EDITOR
8+
79
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
810
from cms.djangoapps.contentstore.views.tests.test_certificates import HelperMethods
11+
from openedx.core.djangoapps.authz.tests.mixins import CourseAuthoringAuthzTestMixin
912

1013
from ...mixins import PermissionAccessMixin
1114

@@ -36,3 +39,35 @@ def test_success_response(self):
3639
self.assertEqual(response_data["course_number_override"], self.course.display_coursenumber)
3740
self.assertEqual(response_data["course_title"], self.course.display_name_with_default)
3841
self.assertEqual(response_data["course_number"], self.course.number)
42+
43+
44+
class CourseCertificatesAuthzViewTest(
45+
CourseAuthoringAuthzTestMixin, CourseTestCase, PermissionAccessMixin, HelperMethods
46+
):
47+
"""
48+
Tests for CourseCertificatesView with AuthZ enabled.
49+
"""
50+
51+
def setUp(self):
52+
super().setUp()
53+
self.url = reverse(
54+
"cms.djangoapps.contentstore:v1:certificates",
55+
kwargs={"course_id": self.course.id},
56+
)
57+
58+
def test_authorized_user_can_access(self):
59+
"""User with COURSE_STAFF role can access."""
60+
self._add_course_certificates(count=2, signatory_count=2)
61+
self.add_user_to_role_in_course(self.authorized_user, COURSE_STAFF.external_key, self.course.id)
62+
resp = self.authorized_client.get(self.url)
63+
self.assertEqual(resp.status_code, status.HTTP_200_OK)
64+
65+
def test_non_staff_user_cannot_access(self):
66+
"""
67+
User without permissions should be denied.
68+
This case validates that a non-staff user cannot access.
69+
"""
70+
self._add_course_certificates(count=2, signatory_count=2)
71+
self.add_user_to_role_in_course(self.authorized_user, COURSE_EDITOR.external_key, self.course.id)
72+
resp = self.authorized_client.get(self.url)
73+
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def _create_block_from_upstream(
128128
"library_content_key": upstream_key,
129129
}, expect_response=expect_response)
130130

131-
def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] = None):
131+
def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] | None = None):
132132
""" Update fields of an XBlock """
133133
return self._api('patch', f"/xblock/{usage_key}", {
134134
"metadata": fields,
@@ -158,7 +158,7 @@ def _get_downstream_links(
158158
data["use_top_level_parents"] = str(use_top_level_parents)
159159
return self.client.get("/api/contentstore/v2/downstreams/", data=data)
160160

161-
def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool:
161+
def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> None:
162162
""" Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """
163163
self.assertEqual(
164164
ElementTree.canonicalize(xml_str_a, strip_text=True),

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from freezegun import freeze_time
1212
from opaque_keys.edx.keys import ContainerKey, UsageKey
1313
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
14+
from openedx_content import models_api as content_models
1415
from organizations.models import Organization
1516

1617
from cms.djangoapps.contentstore.helpers import StaticFileNotices
@@ -221,6 +222,11 @@ def setUp(self):
221222
self._publish_library_block(self.video_lib_id)
222223
self._publish_library_block(self.html_lib_id)
223224

225+
def tearDown(self):
226+
# If we're working with Containers in test cases, we need this line:
227+
content_models.Container.reset_cache()
228+
return super().tearDown()
229+
224230
def _api(self, method, url, data, expect_response):
225231
"""
226232
Call a REST API

cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@
33
"""
44

55
from collections import OrderedDict
6-
from datetime import datetime, timedelta
6+
from datetime import datetime, timedelta, timezone
77

88
import ddt
9-
import pytz
109
from django.conf import settings
1110
from django.urls import reverse
1211
from rest_framework import status
@@ -36,7 +35,7 @@ def setUp(self):
3635
display_name="Demo Course (Sample)",
3736
id=archived_course_key,
3837
org=archived_course_key.org,
39-
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
38+
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=timezone.utc),
4039
)
4140
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
4241

@@ -256,7 +255,7 @@ def test_filter_and_ordering_courses(
256255
display_name="Course (Demo)",
257256
id=archived_course_key,
258257
org=archived_course_key.org,
259-
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
258+
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=timezone.utc),
260259
)
261260
active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run")
262261
CourseOverviewFactory.create(

cms/djangoapps/contentstore/tests/test_utils.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from django.conf import settings
1010
from django.test import TestCase
1111
from django.test.utils import override_settings
12-
from edx_toggles.toggles.testutils import override_waffle_flag
1312
from opaque_keys.edx.keys import CourseKey
1413
from opaque_keys.edx.locator import CourseLocator, LibraryLocator
1514
from openedx_events.tests.utils import OpenEdxEventsTestMixin
@@ -24,7 +23,6 @@
2423
from cms.djangoapps.contentstore.utils import send_course_update_notification
2524
from common.djangoapps.student.models import CourseEnrollment
2625
from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
27-
from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
2826
from openedx.core.djangoapps.notifications.models import Notification
2927
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
3028
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
@@ -938,7 +936,6 @@ def test_update_course_details_instructor_paced(self, mock_update):
938936
mock_update.assert_called_once_with(self.course.id, payload, mock_request.user)
939937

940938

941-
@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True)
942939
class CourseUpdateNotificationTests(OpenEdxEventsTestMixin, ModuleStoreTestCase):
943940
"""
944941
Unit tests for the course_update notification.

0 commit comments

Comments
 (0)