Skip to content

Commit 9ff8d3a

Browse files
committed
feat(poc): implement content restriction based on new user groups
1 parent ff1da66 commit 9ff8d3a

16 files changed

Lines changed: 347 additions & 10 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,6 @@ docs/openedx_user_groups.*.rst
6363
# Private requirements
6464
requirements/private.in
6565
requirements/private.txt
66+
67+
# VSCode
68+
.vscode/

openedx_user_groups/partitions/__init__.py

Whitespace-only changes.
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""
2+
Provides a UserPartition driver for user groups.
3+
"""
4+
5+
import logging
6+
7+
from django.utils.translation import gettext_lazy as _
8+
from lms.djangoapps.courseware.masquerade import (
9+
get_course_masquerade,
10+
get_masquerading_user_group,
11+
is_masquerading_as_specific_student,
12+
)
13+
from opaque_keys.edx.keys import CourseKey
14+
from openedx.core import types
15+
from xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
16+
17+
from openedx_user_groups.models import UserGroup, UserGroupMembership
18+
from openedx_user_groups.toggles import is_user_groups_enabled
19+
20+
log = logging.getLogger(__name__)
21+
22+
# TODO: This is a temporary ID. We should use a more permanent ID.
23+
USER_GROUP_PARTITION_ID = 1000000000
24+
USER_GROUP_SCHEME = "user_group"
25+
26+
27+
class UserGroupPartition(UserPartition):
28+
"""
29+
Extends UserPartition to support dynamic groups pulled from the new user
30+
groups system.
31+
"""
32+
33+
@property
34+
def groups(self):
35+
"""
36+
Dynamically generate groups (based on user groups) for this partition.
37+
"""
38+
course_key = CourseKey.from_string(self.parameters["course_id"])
39+
if not is_user_groups_enabled(course_key):
40+
return []
41+
42+
# TODO: Only get user groups for the course.
43+
user_groups = UserGroup.objects.filter(enabled=True)
44+
return [Group(user_group.id, str(user_group.name)) for user_group in user_groups]
45+
46+
47+
class UserGroupPartitionScheme:
48+
"""Uses user groups to map learners into partition groups.
49+
50+
This scheme is only available if the ENABLE_USER_GROUPS waffle flag is enabled for the course.
51+
52+
This is how it works:
53+
- A only one user partition is created for each course with the `USER_GROUP_PARTITION_ID`.
54+
- A (Content) group is created for each user group in the course with the
55+
database user group ID as the group ID, and the user group name as the
56+
group name.
57+
- A user is assigned to a group if they are a member of the user group.
58+
"""
59+
60+
@classmethod
61+
def get_group_for_user(
62+
cls, course_key: CourseKey, user: types.User, user_partition: UserPartition
63+
) -> list[Group] | None:
64+
"""Get the (User) Group from the specified user partition for the user.
65+
66+
A user is assigned to the group via their user group membership and any
67+
mappings from user groups to partitions / groups that might exist.
68+
69+
Args:
70+
course_key (CourseKey): The course key.
71+
user (types.User): The user.
72+
user_partition (UserPartition): The user partition.
73+
74+
Returns:
75+
List[Group]: The groups in the specified user partition for the user.
76+
None if the user is not a member of any group.
77+
"""
78+
if not is_user_groups_enabled(course_key):
79+
return None
80+
81+
# TODO: A user could belong to multiple groups. This method assumes that
82+
# the user belongs to a single group. This should be renamed?
83+
if get_course_masquerade(user, course_key) and not is_masquerading_as_specific_student(user, course_key):
84+
return get_masquerading_user_group(course_key, user, user_partition)
85+
86+
user_group_ids = UserGroupMembership.objects.filter(user=user, is_active=True).values_list(
87+
"group__id", flat=True
88+
)
89+
all_user_groups: list[UserGroup] = UserGroup.objects.filter(enabled=True)
90+
91+
if not user_group_ids:
92+
return None
93+
94+
user_groups = []
95+
for user_group in all_user_groups:
96+
if user_group.id in user_group_ids:
97+
user_groups.append(Group(user_group.id, str(user_group.name)))
98+
99+
return user_groups
100+
101+
# pylint: disable=redefined-builtin, invalid-name
102+
@classmethod
103+
def create_user_partition(
104+
cls,
105+
id: int,
106+
name: str,
107+
description: str,
108+
groups: list[Group] | None = None,
109+
parameters: dict | None = None,
110+
active: bool = True,
111+
) -> UserPartition:
112+
"""Create a custom UserPartition to support dynamic groups based on user groups.
113+
114+
A Partition has an id, name, scheme, description, parameters, and a
115+
list of groups. The id is intended to be unique within the context where
116+
these are used. (e.g., for partitions of users within a course, the ids
117+
should be unique per-course).
118+
119+
The scheme is used to assign users into groups. The parameters field is
120+
used to save extra parameters e.g., location of the course ID for this
121+
partition scheme.
122+
123+
Partitions can be marked as inactive by setting the "active" flag to False.
124+
Any group access rule referencing inactive partitions will be ignored
125+
when performing access checks.
126+
127+
Args:
128+
id (int): The id of the partition.
129+
name (str): The name of the partition.
130+
description (str): The description of the partition.
131+
groups (list of Group): The groups in the partition.
132+
parameters (dict): The parameters for the partition.
133+
active (bool): Whether the partition is active.
134+
135+
Returns:
136+
UserGroupPartition: The user partition.
137+
"""
138+
course_key = CourseKey.from_string(parameters["course_id"])
139+
if not is_user_groups_enabled(course_key):
140+
return None
141+
142+
user_group_partition = UserGroupPartition(
143+
id,
144+
str(name),
145+
str(description),
146+
groups,
147+
cls,
148+
parameters,
149+
active=active,
150+
)
151+
152+
return user_group_partition
153+
154+
155+
def create_user_group_partition_with_course_id(course_id):
156+
"""
157+
Create and return the user group partition based only on course_id.
158+
If it cannot be created, None is returned.
159+
"""
160+
try:
161+
user_group_scheme = UserPartition.get_scheme(USER_GROUP_SCHEME)
162+
except UserPartitionError:
163+
log.warning(f"No {USER_GROUP_SCHEME} scheme registered, UserGroupPartition will not be created.")
164+
return None
165+
166+
partition = user_group_scheme.create_user_partition(
167+
id=USER_GROUP_PARTITION_ID,
168+
name=_("User Groups"),
169+
description=_("Partition for segmenting users by user groups"),
170+
parameters={"course_id": str(course_id)},
171+
)
172+
173+
return partition
174+
175+
176+
def create_user_group_partition(course):
177+
"""
178+
Get the dynamic user group user partition based on the user groups of the course.
179+
"""
180+
if not is_user_groups_enabled(course.id):
181+
return []
182+
183+
return create_user_group_partition_with_course_id(course.id)

openedx_user_groups/processors/__init__.py

Whitespace-only changes.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""
2+
Outline processors for applying user group partition groups.
3+
"""
4+
5+
from datetime import datetime
6+
from typing import Dict, Set
7+
8+
from opaque_keys.edx.keys import CourseKey
9+
from openedx.core import types
10+
from openedx.core.djangoapps.content.learning_sequences.api.processors.base import OutlineProcessor
11+
from xmodule.partitions.partitions import Group
12+
from xmodule.partitions.partitions_service import get_user_partition_groups
13+
14+
from openedx_user_groups.partitions.user_group_partition_scheme import (
15+
USER_GROUP_PARTITION_ID,
16+
create_user_group_partition_with_course_id,
17+
)
18+
from openedx_user_groups.toggles import is_user_groups_enabled
19+
20+
21+
class UserGroupPartitionGroupsOutlineProcessor(OutlineProcessor):
22+
"""
23+
Processor for applying all user partition groups to the course outline.
24+
25+
This processor is used to remove content from the course outline based on
26+
the user's user group membership. It is used in the courseware API to remove
27+
content from the course outline before it is returned to the client.
28+
"""
29+
30+
def __init__(self, course_key: CourseKey, user: types.User, at_time: datetime):
31+
"""
32+
Initialize the UserGroupPartitionGroupsOutlineProcessor.
33+
34+
Args:
35+
course_key (CourseKey): The course key.
36+
user (types.User): The user.
37+
at_time (datetime): The time at which the data is loaded.
38+
"""
39+
super().__init__(course_key, user, at_time)
40+
self.user_groups: Dict[str, Group] = {}
41+
42+
def load_data(self, _) -> None:
43+
"""
44+
Pull user groups for this course and which group the user is in.
45+
"""
46+
if not is_user_groups_enabled(self.course_key):
47+
return
48+
49+
user_partition = create_user_group_partition_with_course_id(self.course_key)
50+
self.user_groups = get_user_partition_groups(
51+
self.course_key,
52+
[user_partition],
53+
self.user,
54+
partition_dict_key="id",
55+
).get(USER_GROUP_PARTITION_ID)
56+
57+
def _is_user_excluded_by_partition_group(self, user_partition_groups: Dict[int, Set[int]]):
58+
"""
59+
Is the user part of the group to which the block is restricting content?
60+
61+
Args:
62+
user_partition_groups (Dict[int, Set(int)]): Mapping from partition
63+
ID to the groups to which the user belongs in that partition.
64+
65+
Returns:
66+
bool: True if the user is excluded from the content, False otherwise.
67+
The user is excluded from the content if and only if, for a non-empty
68+
partition group, the user is not in any of the groups for that partition.
69+
"""
70+
if not is_user_groups_enabled(self.course_key):
71+
return False
72+
73+
if not user_partition_groups:
74+
return False
75+
76+
groups = user_partition_groups.get(USER_GROUP_PARTITION_ID)
77+
if not groups:
78+
return False
79+
80+
for group in self.user_groups:
81+
if group.id in groups:
82+
return False
83+
84+
return True
85+
86+
def usage_keys_to_remove(self, full_course_outline):
87+
"""
88+
Content group exclusions remove the content entirely.
89+
90+
This method returns the usage keys of all content that should be
91+
removed from the course outline based on the user's user group membership.
92+
"""
93+
removed_usage_keys = set()
94+
95+
for section in full_course_outline.sections:
96+
remove_all_children = False
97+
98+
if self._is_user_excluded_by_partition_group(section.user_partition_groups):
99+
removed_usage_keys.add(section.usage_key)
100+
remove_all_children = True
101+
102+
for seq in section.sequences:
103+
if remove_all_children or self._is_user_excluded_by_partition_group(seq.user_partition_groups):
104+
removed_usage_keys.add(seq.usage_key)
105+
106+
return removed_usage_keys

openedx_user_groups/toggles.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Toggles for user groups.
3+
This module defines feature flags (waffle flags) used to enable or disable functionality related to user groups
4+
within the Open edX platform. These toggles allow for dynamic control of features without requiring code changes.
5+
"""
6+
7+
from opaque_keys.edx.keys import CourseKey
8+
9+
try:
10+
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
11+
except ImportError:
12+
CourseWaffleFlag = None
13+
14+
# Namespace for all user group related waffle flags
15+
WAFFLE_FLAG_NAMESPACE = "user_groups"
16+
17+
# .. toggle_name: user_groups.enable_user_groups
18+
# .. toggle_implementation: CourseWaffleFlag
19+
# .. toggle_default: False
20+
# .. toggle_description: Waffle flag to enable or disable the user groups feature in a course.
21+
# .. toggle_use_cases: temporary, open_edx
22+
# .. toggle_creation_date: 2025-06-19
23+
# .. toggle_target_removal_date: None
24+
ENABLE_USER_GROUPS = CourseWaffleFlag(f"{WAFFLE_FLAG_NAMESPACE}.enable_user_groups", __name__)
25+
26+
27+
def is_user_groups_enabled(course_key: CourseKey) -> bool:
28+
"""
29+
Returns a boolean if user groups are enabled for the course.
30+
"""
31+
return ENABLE_USER_GROUPS.is_enabled(course_key)

requirements/base.in

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33

44
Django # Web application framework
55
edx-django-utils # edX utilities for Django
6-
76
openedx-atlas
87
openedx-events # Open edX Events library for updating user groups
98
celery # Celery for background tasks
109
djangorestframework
1110
edx-organizations
1211
pydantic
12+
edx-opaque-keys # Open edX opaque keys library

requirements/base.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.12
2+
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
55
# pip-compile --output-file=requirements/base.txt requirements/base.in
@@ -85,6 +85,7 @@ edx-drf-extensions==10.6.0
8585
# via edx-organizations
8686
edx-opaque-keys[django]==3.0.0
8787
# via
88+
# -r requirements/base.in
8889
# edx-ccx-keys
8990
# edx-drf-extensions
9091
# edx-organizations

requirements/ci.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.12
2+
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
55
# pip-compile --output-file=requirements/ci.txt requirements/ci.in

requirements/dev.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.12
2+
# This file is autogenerated by pip-compile with Python 3.11
33
# by the following command:
44
#
55
# pip-compile --output-file=requirements/dev.txt requirements/dev.in

0 commit comments

Comments
 (0)