Skip to content

Commit f7e8036

Browse files
Merge branch 'master' into ktyagi/primaryemail
2 parents 2593ac2 + 7e42ce3 commit f7e8036

219 files changed

Lines changed: 14652 additions & 4337 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.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Create quarterly issues for GitHub audit
2+
on:
3+
schedule:
4+
- cron: 0 0 1 1,4,7,10 *
5+
workflow_dispatch: {}
6+
7+
env:
8+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
9+
10+
jobs:
11+
create_issue:
12+
name: Create quarterly constraint check issue
13+
runs-on: ubuntu-latest
14+
permissions:
15+
issues: write
16+
steps:
17+
- run: |
18+
# Platform constraints audit
19+
new_issue_url=$(gh issue create --repo "openedx/openedx-platform" \
20+
--title "Quarterly audit of openedx-platform constraints" \
21+
--label "code health" \
22+
--body "It is time to perform the quartely audit of constrained dependencies in \`openedx-platform\`. The goal is to remove any constraints that are no longer necessary to proactively prevent version conflicts and keep us up to date with security patches. The playbook for performing the audit can be found [here](https://openedx.atlassian.net/wiki/spaces/AC/pages/6340968449/Quarterly+Platform+Constraints+Audit).")
23+
echo "NEW_ISSUE_URL=$new_issue_url" >> $GITHUB_ENV
24+
25+
- name: Comment on issue
26+
run: gh issue comment $NEW_ISSUE_URL --body "@openedx/wg-maintenance-openedx-platform-oncall heads up on this request"

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,11 @@ Code of Conduct
293293

294294
Please read the `Community Code of Conduct`_ for interacting with this repository.
295295

296+
AI Contribution Policy
297+
**********************
298+
299+
Note that contributions are expected to follow the Open edX `AI Contribution Policy`_.
300+
296301
Reporting Security Issues
297302
*************************
298303

@@ -302,6 +307,7 @@ [email protected].
302307
.. _individual contributor agreement: https://openedx.org/cla
303308
.. _CONTRIBUTING: https://github.com/openedx/.github/blob/master/CONTRIBUTING.md
304309
.. _Community Code of Conduct: https://openedx.org/code-of-conduct/
310+
.. _AI Contribution Policy: https://github.com/openedx/.github/blob/master/AI_POLICY.md
305311

306312
People
307313
******

cms/djangoapps/contentstore/helpers.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -509,8 +509,18 @@ def _fetch_and_set_upstream_link(
509509
# temp_xblock.display_name == temp_xblock.upstream_display_name
510510
# temp_xblock.data == temp_xblock.upstream_data # for html blocks
511511
# Even then we want to set `downstream_customized` value to avoid overriding user customisations on sync
512-
downstream_customized = temp_xblock.xml_attributes.get("downstream_customized", '[]')
513-
temp_xblock.downstream_customized = json.loads(downstream_customized)
512+
downstream_customized = getattr(temp_xblock, "downstream_customized", [])
513+
# XmlMixin blocks expose raw XML attrs on `xml_attributes`; other blocks (e.g. DnD)
514+
# may not have this attribute, but still have parsed downstream_customized field.
515+
xml_attributes = getattr(temp_xblock, "xml_attributes", None)
516+
if isinstance(xml_attributes, dict):
517+
raw_downstream_customized = xml_attributes.get("downstream_customized")
518+
if isinstance(raw_downstream_customized, str):
519+
downstream_customized = json.loads(raw_downstream_customized)
520+
elif isinstance(raw_downstream_customized, list):
521+
downstream_customized = raw_downstream_customized
522+
if hasattr(temp_xblock, "downstream_customized"):
523+
temp_xblock.downstream_customized = downstream_customized
514524

515525

516526
def _import_xml_node_to_parent(

cms/djangoapps/contentstore/models.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from opaque_keys.edx.locator import LibraryContainerLocator
1818
from openedx_content.api import get_published_version
1919
from openedx_content.models_api import Component, Container
20-
from openedx_django_lib.fields import immutable_uuid_field, key_field, manual_date_time_field
20+
from openedx_django_lib.fields import immutable_uuid_field, manual_date_time_field, ref_field
2121

2222
logger = logging.getLogger(__name__)
2323

@@ -87,7 +87,7 @@ class EntityLinkBase(models.Model):
8787
"""
8888
uuid = immutable_uuid_field()
8989
# Search by library/upstream context key
90-
upstream_context_key = key_field(
90+
upstream_context_key = ref_field(
9191
help_text=_("Upstream context key i.e., learning_package/library key"),
9292
db_index=True,
9393
)

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ def setUp(self):
274274
collection_key = "test-collection"
275275
content_api.create_collection(
276276
learning_package_id=learning_package.id,
277-
key=collection_key,
277+
collection_code=collection_key,
278278
title="Test Collection",
279279
created_by=self.user.id,
280280
)
@@ -329,7 +329,7 @@ def test_home_page_libraries_response(self):
329329
'can_edit': True,
330330
'is_migrated': True,
331331
'migrated_to_title': 'Test Library',
332-
'migrated_to_key': 'lib:name0:test-key',
332+
'migrated_to_key': str(self.lib_key_v2),
333333
'migrated_to_collection_key': 'test-collection',
334334
'migrated_to_collection_title': 'Test Collection',
335335
},
@@ -364,7 +364,7 @@ def test_home_page_libraries_response(self):
364364
'can_edit': True,
365365
'is_migrated': True,
366366
'migrated_to_title': 'Test Library',
367-
'migrated_to_key': 'lib:name0:test-key',
367+
'migrated_to_key': str(self.lib_key_v2),
368368
'migrated_to_collection_key': 'test-collection',
369369
'migrated_to_collection_title': 'Test Collection',
370370
}

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.

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
import ddt
77
from opaque_keys.edx.keys import UsageKey
8+
from openedx_content.api import signals as content_signals
89
from openedx_events.content_authoring.signals import (
910
LIBRARY_BLOCK_DELETED,
1011
XBLOCK_CREATED,
@@ -405,6 +406,7 @@ class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ImmediateOnCom
405406
Test Clipboard Paste functionality with a "new" (as of Sumac) library
406407
"""
407408
ENABLED_OPENEDX_EVENTS = [
409+
content_signals.ENTITIES_DRAFT_CHANGED.event_type, # Required for library events to work
408410
LIBRARY_BLOCK_DELETED.event_type,
409411
XBLOCK_CREATED.event_type,
410412
XBLOCK_DELETED.event_type,
@@ -491,7 +493,8 @@ def test_paste_from_library_read_only_tags(self):
491493
assert object_tag.is_copied
492494

493495
# If we delete the upstream library block...
494-
library_api.delete_library_block(self.lib_block_key)
496+
with self.captureOnCommitCallbacks(execute=True): # make event handlers fire now, within TestCase transaction
497+
library_api.delete_library_block(self.lib_block_key)
495498

496499
# ...the copied tags remain, but should no longer be marked as "copied"
497500
object_tags = tagging_api.get_object_tags(new_block_key)

cms/djangoapps/modulestore_migrator/api/read_api.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ def get_migrations(
135135
if source_key:
136136
migrations = migrations.filter(source__key=source_key)
137137
if target_key:
138-
migrations = migrations.filter(target__key=str(target_key))
138+
migrations = migrations.filter(target__package_ref=str(target_key))
139139
if target_collection_slug:
140-
migrations = migrations.filter(target_collection__key=target_collection_slug)
140+
migrations = migrations.filter(target_collection__collection_code=target_collection_slug)
141141
if task_uuid:
142142
migrations = migrations.filter(task_status__uuid=task_uuid)
143143
if is_failed is not None:
@@ -176,9 +176,9 @@ def _migration(m: models.ModulestoreMigration) -> ModulestoreMigration:
176176
return ModulestoreMigration(
177177
pk=m.id,
178178
source_key=m.source.key,
179-
target_key=LibraryLocatorV2.from_string(m.target.key),
179+
target_key=LibraryLocatorV2.from_string(m.target.package_ref),
180180
target_title=m.target.title,
181-
target_collection_slug=(m.target_collection.key if m.target_collection else None),
181+
target_collection_slug=(m.target_collection.collection_code if m.target_collection else None),
182182
target_collection_title=(m.target_collection.title if m.target_collection else None),
183183
is_failed=m.is_failed,
184184
task_uuid=m.task_status.uuid,
@@ -209,7 +209,7 @@ def _block_migration_success(
209209
"""
210210
Build an instance of the migration success dataclass
211211
"""
212-
target_library_key = LibraryLocatorV2.from_string(target.learning_package.key)
212+
target_library_key = LibraryLocatorV2.from_string(target.learning_package.package_ref)
213213
target_key: LibraryUsageLocatorV2 | LibraryContainerLocator
214214
if hasattr(target, "component"):
215215
target_key = library_component_usage_key(target_library_key, target.component)

cms/djangoapps/modulestore_migrator/rest_api/v1/serializers.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ class LibraryMigrationCollectionSerializer(serializers.ModelSerializer):
1818
"""
1919
Serializer for the target collection of a library migration.
2020
"""
21+
# Expose Collection.collection_code as "key" to preserve the REST API field name.
22+
# This is temporary: https://github.com/openedx/openedx-platform/issues/38406
23+
key = serializers.CharField(source='collection_code')
24+
2125
class Meta:
2226
model = Collection
2327
fields = ["key", "title"]

0 commit comments

Comments
 (0)