Skip to content

Commit 923c763

Browse files
authored
Merge branch 'master' into ttqureshi/enable-lti
2 parents 266dcfa + 50da280 commit 923c763

File tree

12 files changed

+681
-12
lines changed

12 files changed

+681
-12
lines changed

cms/djangoapps/contentstore/course_group_config.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,21 @@
1212
from cms.djangoapps.contentstore.utils import reverse_usage_url
1313
from common.djangoapps.util.db import MYSQL_MAX_INT, generate_int_id
1414
from lms.lib.utils import get_parent_unit
15+
# Re-exported for backward compatibility - other modules import these from here
16+
from openedx.core.djangoapps.course_groups.constants import ( # pylint: disable=unused-import
17+
COHORT_SCHEME,
18+
CONTENT_GROUP_CONFIGURATION_DESCRIPTION,
19+
CONTENT_GROUP_CONFIGURATION_NAME,
20+
ENROLLMENT_SCHEME,
21+
RANDOM_SCHEME,
22+
)
1523
from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition
1624
from xmodule.partitions.partitions import MINIMUM_UNUSED_PARTITION_ID, ReadOnlyUserPartitionError, UserPartition # lint-amnesty, pylint: disable=wrong-import-order
1725
from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order
1826
from xmodule.split_test_block import get_split_user_partitions # lint-amnesty, pylint: disable=wrong-import-order
1927

2028
MINIMUM_GROUP_ID = MINIMUM_UNUSED_PARTITION_ID
2129

22-
RANDOM_SCHEME = "random"
23-
COHORT_SCHEME = "cohort"
24-
ENROLLMENT_SCHEME = "enrollment_track"
25-
26-
CONTENT_GROUP_CONFIGURATION_DESCRIPTION = _(
27-
'The groups in this configuration can be mapped to cohorts in the Instructor Dashboard.'
28-
)
29-
30-
CONTENT_GROUP_CONFIGURATION_NAME = _('Content Groups')
31-
3230
log = logging.getLogger(__name__)
3331

3432

lms/djangoapps/bulk_email/signals.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from eventtracking import tracker
88

99
from common.djangoapps.student.models import CourseEnrollment
10+
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
1011
from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS
1112
from edx_ace.signals import ACE_MESSAGE_SENT
1213

@@ -27,7 +28,14 @@ def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused-
2728
raise TypeError('Expected a User type, but received None.')
2829

2930
for enrollment in CourseEnrollment.objects.filter(user=user):
30-
Optout.objects.get_or_create(user=user, course_id=enrollment.course.id)
31+
try:
32+
Optout.objects.get_or_create(user=user, course_id=enrollment.course.id)
33+
except CourseOverview.DoesNotExist:
34+
log.warning(
35+
f"CourseOverview not found for enrollment {enrollment.id} (user: {user.id}), "
36+
f"skipping optout creation. This may mean the course was deleted."
37+
)
38+
continue
3139

3240

3341
@receiver(ACE_MESSAGE_SENT)

lms/djangoapps/bulk_email/tests/test_signals.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@
1010
from django.core.management import call_command
1111
from django.urls import reverse
1212

13+
from common.djangoapps.student.models import CourseEnrollment
1314
from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory
1415
from lms.djangoapps.bulk_email.models import BulkEmailFlag, Optout
1516
from lms.djangoapps.bulk_email.signals import force_optout_all
17+
from opaque_keys.edx.keys import CourseKey
1618
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
1719
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
1820

@@ -85,3 +87,41 @@ def test_optout_course(self):
8587
assert len(mail.outbox) == 1
8688
assert len(mail.outbox[0].to) == 1
8789
assert mail.outbox[0].to[0] == self.instructor.email
90+
91+
@patch('lms.djangoapps.bulk_email.signals.log.warning')
92+
def test_optout_handles_missing_course_overview(self, mock_log_warning):
93+
"""
94+
Test that force_optout_all gracefully handles CourseEnrollments
95+
with missing CourseOverview records
96+
"""
97+
# Create a course key for a course that doesn't exist in CourseOverview
98+
nonexistent_course_key = CourseKey.from_string('course-v1:TestX+Missing+2023')
99+
100+
# Create an enrollment with a course_id that doesn't have a CourseOverview
101+
CourseEnrollment.objects.create(
102+
user=self.student,
103+
course_id=nonexistent_course_key,
104+
mode='honor'
105+
)
106+
107+
# Verify the orphaned enrollment exists
108+
assert CourseEnrollment.objects.filter(
109+
user=self.student,
110+
course_id=nonexistent_course_key
111+
).exists()
112+
113+
force_optout_all(sender=self.__class__, user=self.student)
114+
115+
# Verify that a warning was logged for the missing CourseOverview
116+
mock_log_warning.assert_called()
117+
call_args = mock_log_warning.call_args[0][0]
118+
assert "CourseOverview not found for enrollment" in call_args
119+
assert f"user: {self.student.id}" in call_args
120+
assert "skipping optout creation" in call_args
121+
122+
# Verify that optouts were created for valid courses only
123+
valid_course_optouts = Optout.objects.filter(user=self.student, course_id=self.course.id)
124+
missing_course_optouts = Optout.objects.filter(user=self.student, course_id=nonexistent_course_key)
125+
126+
assert valid_course_optouts.count() == 1
127+
assert missing_course_optouts.count() == 0
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""
2+
Constants for course groups.
3+
"""
4+
from django.utils.translation import gettext_lazy as _
5+
6+
COHORT_SCHEME = 'cohort'
7+
RANDOM_SCHEME = 'random'
8+
ENROLLMENT_SCHEME = 'enrollment_track'
9+
10+
CONTENT_GROUP_CONFIGURATION_NAME = _('Content Groups')
11+
CONTENT_GROUP_CONFIGURATION_DESCRIPTION = _(
12+
'Use this group configuration to control access to content.'
13+
)

openedx/core/djangoapps/course_groups/rest_api/__init__.py

Whitespace-only changes.
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
swagger: '2.0'
2+
info:
3+
title: Content Groups API v2
4+
version: 2.0.0
5+
description: |
6+
REST API for managing content group configurations.
7+
8+
Content groups allow course authors to restrict access to specific
9+
course content based on cohort membership.
10+
11+
host: courses.example.com
12+
basePath: /
13+
schemes:
14+
- https
15+
16+
securityDefinitions:
17+
JWTAuth:
18+
type: apiKey
19+
in: header
20+
name: Authorization
21+
description: JWT token authentication.
22+
23+
security:
24+
- JWTAuth: []
25+
26+
tags:
27+
- name: Content Groups
28+
description: Content group configuration management
29+
30+
parameters:
31+
CourseId:
32+
name: course_id
33+
in: path
34+
required: true
35+
type: string
36+
description: The course key (e.g., course-v1:org+course+run)
37+
ConfigurationId:
38+
name: configuration_id
39+
in: path
40+
required: true
41+
type: integer
42+
description: The ID of the content group configuration
43+
44+
paths:
45+
/api/cohorts/v2/courses/{course_id}/group_configurations:
46+
get:
47+
tags:
48+
- Content Groups
49+
summary: List content group configurations
50+
description: |
51+
Returns all content group configurations (scheme='cohort') for a course.
52+
If no content group exists, an empty one is automatically created.
53+
operationId: listGroupConfigurations
54+
produces:
55+
- application/json
56+
parameters:
57+
- $ref: '#/parameters/CourseId'
58+
responses:
59+
200:
60+
description: Content groups retrieved successfully
61+
schema:
62+
$ref: '#/definitions/ContentGroupsListResponse'
63+
400:
64+
description: Invalid course key
65+
401:
66+
description: Authentication required
67+
403:
68+
description: User lacks instructor permission
69+
404:
70+
description: Course not found
71+
72+
/api/cohorts/v2/courses/{course_id}/group_configurations/{configuration_id}:
73+
get:
74+
tags:
75+
- Content Groups
76+
summary: Get content group configuration details
77+
description: |
78+
Retrieve a specific content group configuration by ID.
79+
Only returns configurations with scheme='cohort'.
80+
operationId: getGroupConfiguration
81+
produces:
82+
- application/json
83+
parameters:
84+
- $ref: '#/parameters/CourseId'
85+
- $ref: '#/parameters/ConfigurationId'
86+
responses:
87+
200:
88+
description: Configuration retrieved successfully
89+
schema:
90+
$ref: '#/definitions/ContentGroupConfiguration'
91+
400:
92+
description: Invalid course key
93+
401:
94+
description: Authentication required
95+
403:
96+
description: User lacks instructor permission
97+
404:
98+
description: Configuration not found
99+
100+
definitions:
101+
Group:
102+
type: object
103+
properties:
104+
id:
105+
type: integer
106+
description: Unique identifier for the group
107+
name:
108+
type: string
109+
description: Display name of the group
110+
version:
111+
type: integer
112+
description: Version number of the group
113+
usage:
114+
type: array
115+
items:
116+
type: object
117+
description: List of content blocks using this group
118+
119+
ContentGroupConfiguration:
120+
type: object
121+
properties:
122+
id:
123+
type: integer
124+
description: Unique identifier for the configuration
125+
name:
126+
type: string
127+
description: Display name (typically "Content Groups")
128+
scheme:
129+
type: string
130+
enum: [cohort]
131+
description: Partition scheme type
132+
description:
133+
type: string
134+
description: Human-readable description
135+
parameters:
136+
type: object
137+
description: Additional configuration parameters
138+
groups:
139+
type: array
140+
items:
141+
$ref: '#/definitions/Group'
142+
description: List of groups in this configuration
143+
active:
144+
type: boolean
145+
description: Whether this configuration is active
146+
version:
147+
type: integer
148+
description: Version number of the configuration
149+
read_only:
150+
type: boolean
151+
description: Whether this configuration is system-managed
152+
153+
ContentGroupsListResponse:
154+
type: object
155+
properties:
156+
all_group_configurations:
157+
type: array
158+
items:
159+
$ref: '#/definitions/ContentGroupConfiguration'
160+
description: List of content group configurations
161+
should_show_enrollment_track:
162+
type: boolean
163+
description: Whether enrollment track groups should be displayed
164+
should_show_experiment_groups:
165+
type: boolean
166+
description: Whether experiment groups should be displayed
167+
group_configuration_url:
168+
type: string
169+
description: Base URL for accessing individual configurations
170+
course_outline_url:
171+
type: string
172+
description: URL to the course outline
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""
2+
Serializers for content group configurations REST API.
3+
"""
4+
from rest_framework import serializers
5+
6+
7+
class GroupSerializer(serializers.Serializer):
8+
"""
9+
Serializer for a single group within a content group configuration.
10+
"""
11+
id = serializers.IntegerField()
12+
name = serializers.CharField(max_length=255)
13+
version = serializers.IntegerField()
14+
usage = serializers.ListField(
15+
child=serializers.DictField(),
16+
required=False,
17+
default=list
18+
)
19+
20+
21+
class ContentGroupConfigurationSerializer(serializers.Serializer):
22+
"""
23+
Serializer for a content group configuration (UserPartition with scheme='cohort').
24+
"""
25+
id = serializers.IntegerField()
26+
name = serializers.CharField(max_length=255)
27+
scheme = serializers.CharField()
28+
description = serializers.CharField(allow_blank=True)
29+
parameters = serializers.DictField()
30+
groups = GroupSerializer(many=True)
31+
active = serializers.BooleanField()
32+
version = serializers.IntegerField()
33+
is_read_only = serializers.BooleanField(required=False, default=False)
34+
35+
36+
class ContentGroupsListResponseSerializer(serializers.Serializer):
37+
"""
38+
Response serializer for listing all content groups.
39+
"""
40+
all_group_configurations = ContentGroupConfigurationSerializer(many=True)
41+
should_show_enrollment_track = serializers.BooleanField()
42+
should_show_experiment_groups = serializers.BooleanField()
43+
context_course = serializers.JSONField(required=False, allow_null=True)
44+
group_configuration_url = serializers.CharField()
45+
course_outline_url = serializers.CharField()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Tests for Content Groups REST API v2.
3+
"""

0 commit comments

Comments
 (0)