|
8 | 8 | from unittest.mock import AsyncMock, MagicMock, patch |
9 | 9 | from uuid import uuid4 |
10 | 10 |
|
| 11 | +import ddt |
11 | 12 | import pytest |
12 | 13 | from celery import Task |
13 | 14 | from django.conf import settings |
14 | 15 | from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user |
| 16 | +from django.db import models |
15 | 17 | from django.test.utils import override_settings |
16 | 18 | from edx_toggles.toggles.testutils import override_waffle_flag |
17 | 19 | from opaque_keys.edx.keys import CourseKey |
|
25 | 27 | from common.djangoapps.course_action_state.models import CourseRerunState |
26 | 28 | from common.djangoapps.student.tests.factories import UserFactory |
27 | 29 | 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 |
28 | 32 | from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse |
29 | 33 | from xmodule.modulestore import ModuleStoreEnum |
30 | 34 | from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order |
|
45 | 49 | export_olx, |
46 | 50 | extract_content_URLs_from_course, |
47 | 51 | rerun_course, |
| 52 | + sync_discussion_settings, |
48 | 53 | update_special_exams_and_publish, |
49 | 54 | ) |
50 | 55 |
|
@@ -672,3 +677,136 @@ def test_extract_content_URLs_from_course(self): |
672 | 677 | "https://another-valid.com" |
673 | 678 | ] |
674 | 679 | 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