Skip to content

Commit 76fdce3

Browse files
committed
feat: sync more discussion settings on course import
This adds synchronization for the following attributes: 1. In-context discussion enabled. 2. Posting restrictions. 3. Plugin configuration (e.g., grouping at the subsection level).
1 parent bece2c4 commit 76fdce3

2 files changed

Lines changed: 146 additions & 2 deletions

File tree

cms/djangoapps/contentstore/tasks.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,8 +523,14 @@ def sync_discussion_settings(course_key, user):
523523

524524
discussion_config.provider_type = Provider.OPEN_EDX
525525

526-
discussion_config.enable_graded_units = discussion_settings['enable_graded_units']
527-
discussion_config.unit_level_visibility = discussion_settings['unit_level_visibility']
526+
fields = ["enable_graded_units", "unit_level_visibility", "enable_in_context", "posting_restrictions"]
527+
# Plugin configuration is stored in the course settings under the provider name.
528+
field_mappings = dict(zip(fields, fields)) | {"plugin_configuration": discussion_config.provider_type}
529+
530+
for attr_name, settings_key in field_mappings.items():
531+
if settings_key in discussion_settings:
532+
setattr(discussion_config, attr_name, discussion_settings[settings_key])
533+
528534
discussion_config.save()
529535
LOGGER.info(f'Course import {course.id}: DiscussionsConfiguration synced as per course')
530536
except Exception as exc: # pylint: disable=broad-except

cms/djangoapps/contentstore/tests/test_tasks.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
from unittest.mock import AsyncMock, MagicMock, patch
99
from uuid import uuid4
1010

11+
import ddt
1112
import pytest
1213
from celery import Task
1314
from django.conf import settings
1415
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
16+
from django.db import models
1517
from django.test.utils import override_settings
1618
from edx_toggles.toggles.testutils import override_waffle_flag
1719
from opaque_keys.edx.keys import CourseKey
@@ -25,6 +27,8 @@
2527
from common.djangoapps.course_action_state.models import CourseRerunState
2628
from common.djangoapps.student.tests.factories import UserFactory
2729
from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA
30+
from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS
31+
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider
2832
from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse
2933
from xmodule.modulestore import ModuleStoreEnum
3034
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
@@ -45,6 +49,7 @@
4549
export_olx,
4650
extract_content_URLs_from_course,
4751
rerun_course,
52+
sync_discussion_settings,
4853
update_special_exams_and_publish,
4954
)
5055

@@ -672,3 +677,136 @@ def test_extract_content_URLs_from_course(self):
672677
"https://another-valid.com"
673678
]
674679
self.assertEqual(extract_content_URLs_from_course(content), set(expected))
680+
681+
682+
@ddt.ddt
683+
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
684+
class SyncDiscussionSettingsTaskTestCase(CourseTestCase):
685+
"""Tests for the `sync_discussion_settings` task."""
686+
687+
def setUp(self):
688+
super().setUp()
689+
self.discussion_config = DiscussionsConfiguration.objects.create(context_key=self.course.id)
690+
691+
def _update_discussion_settings(self, discussions_settings: dict):
692+
"""Helper method to set discussion settings in the course."""
693+
self.course.discussions_settings = discussions_settings
694+
modulestore().update_item(self.course, self.user.id)
695+
696+
def test_sync_settings(self):
697+
"""Test syncing discussion settings to DiscussionsConfiguration."""
698+
self._update_discussion_settings(
699+
{
700+
"enable_graded_units": True,
701+
"unit_level_visibility": False,
702+
"enable_in_context": True,
703+
"posting_restrictions": "enabled",
704+
}
705+
)
706+
707+
sync_discussion_settings(self.course.id, self.user)
708+
709+
self.discussion_config.refresh_from_db()
710+
assert self.discussion_config.enable_graded_units is True
711+
assert self.discussion_config.unit_level_visibility is False
712+
assert self.discussion_config.enable_in_context is True
713+
assert self.discussion_config.posting_restrictions == "enabled"
714+
assert self.discussion_config.provider_type == Provider.LEGACY
715+
716+
def test_sync_plugin_configuration(self):
717+
"""Test syncing plugin configuration from provider settings."""
718+
# Set up course discussion settings with provider-specific config
719+
provider_config = {"test_key": "test_value", "test_key_2": "test_value_2"}
720+
self._update_discussion_settings({self.discussion_config.provider_type: provider_config})
721+
722+
sync_discussion_settings(self.course.id, self.user)
723+
724+
self.discussion_config.refresh_from_db()
725+
assert self.discussion_config.plugin_configuration == provider_config
726+
727+
@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, active=True)
728+
def test_auto_migrate_to_new_structure(self):
729+
"""Test automatic migration to the `OPEN_EDX` provider when new structure is enabled."""
730+
with self.assertLogs("cms.djangoapps.contentstore.tasks", level="INFO") as logs:
731+
sync_discussion_settings(self.course.id, self.user)
732+
733+
migration_log = f"New structure is enabled, also updating {self.course.id} to use new provider"
734+
assert any(migration_log in log for log in logs.output)
735+
736+
self.discussion_config.refresh_from_db()
737+
assert self.discussion_config.provider_type == Provider.OPEN_EDX
738+
739+
course = modulestore().get_course(self.course.id)
740+
assert course.discussions_settings.get("provider_type") == Provider.OPEN_EDX
741+
742+
@ddt.data(
743+
{"provider_type": Provider.OPEN_EDX}, # Using the `provider_type` field.
744+
{"provider": Provider.OPEN_EDX}, # Using the `provider` field as fallback.
745+
)
746+
@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, active=True)
747+
def test_no_provider_migration_when_already_openedx(self, provider_settings: dict):
748+
"""Test no migration occurs when provider is already `OPEN_EDX`."""
749+
self._update_discussion_settings(provider_settings)
750+
751+
with self.assertLogs("cms.djangoapps.contentstore.tasks", level="INFO") as logs:
752+
sync_discussion_settings(self.course.id, self.user)
753+
754+
migration_log = f"New structure is enabled, also updating {self.course.id} to use new provider"
755+
assert not any(migration_log in log for log in logs.output)
756+
757+
def test_all_syncable_fields_are_overridden(self):
758+
"""
759+
Verify that all syncable DiscussionsConfiguration fields are updated during course import.
760+
761+
If this test fails after adding a new field, update `sync_discussion_settings` to handle it.
762+
"""
763+
764+
excluded_fields = {
765+
"context_key", # Primary key - not synced.
766+
"enabled", # Handled separately in `update_discussions_settings_from_course`.
767+
"created", # Auto-generated by TimeStampedModel.
768+
"modified", # Auto-generated by TimeStampedModel.
769+
"plugin_configuration", # Custom logic. Already tested in `test_sync_plugin_configuration`.
770+
"provider_type", # Custom logic. Already tested in `test_auto_migrate_to_new_structure`.
771+
}
772+
773+
test_values = {}
774+
for field in DiscussionsConfiguration._meta.get_fields():
775+
if not getattr(field, "concrete", False):
776+
continue
777+
if field.primary_key or field.name in excluded_fields:
778+
continue
779+
if isinstance(field, (models.ForeignKey, models.ManyToManyField)):
780+
continue
781+
782+
if isinstance(field, models.BooleanField):
783+
test_values[field.name] = not field.default
784+
elif isinstance(field, models.CharField) and field.choices:
785+
test_values[field.name] = next(v for v, _ in field.choices if v != field.default)
786+
elif isinstance(field, models.CharField):
787+
test_values[field.name] = "test_sync_value"
788+
else:
789+
test_values[field.name] = {"synced_key": "synced_value"}
790+
791+
self._update_discussion_settings(test_values)
792+
sync_discussion_settings(self.course.id, self.user)
793+
794+
self.discussion_config.refresh_from_db()
795+
for name, expected in test_values.items():
796+
assert getattr(self.discussion_config, name) == expected, (
797+
f"Field '{name}' was not synced during course import. "
798+
f"Update sync_discussion_settings to handle this field.",
799+
)
800+
801+
def test_handling_exceptions(self):
802+
"""Test that exceptions are caught and logged properly."""
803+
test_error_message = "Test error"
804+
805+
with mock.patch.object(DiscussionsConfiguration.objects, "get", side_effect=Exception(test_error_message)):
806+
with self.assertLogs("cms.djangoapps.contentstore.tasks", level="INFO") as logs:
807+
sync_discussion_settings(self.course.id, self.user)
808+
809+
expected_log = (
810+
f"Course import {self.course.id}: DiscussionsConfiguration sync failed: {test_error_message}"
811+
)
812+
assert any(expected_log in log for log in logs.output)

0 commit comments

Comments
 (0)