Skip to content

Commit cf394ae

Browse files
committed
feat(poc): implement content restriction based on new user groups
1 parent 4c2d679 commit cf394ae

16 files changed

Lines changed: 323 additions & 9 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: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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 xmodule.partitions.partitions import Group, UserPartition, UserPartitionError
14+
15+
from openedx_user_groups.models import UserGroup, UserGroupMembership
16+
17+
log = logging.getLogger(__name__)
18+
19+
20+
USER_GROUP_PARTITION_ID = 1000000000
21+
USER_GROUP_SCHEME = "user_group"
22+
23+
24+
class UserGroupPartition(UserPartition):
25+
"""
26+
Extends UserPartition to support dynamic groups pulled from the new user
27+
groups system.
28+
"""
29+
30+
@property
31+
def groups(self):
32+
"""
33+
Dynamically generate groups (based on user groups) for this partition.
34+
"""
35+
# TODO: Only get user groups for the course.
36+
user_groups = UserGroup.objects.all()
37+
return [Group(user_group.id, str(user_group.name)) for user_group in user_groups]
38+
39+
40+
class UserGroupPartitionScheme:
41+
"""Uses user groups to map learners into partition groups.
42+
43+
- A user partition is created for each user group in the course with a
44+
unused partition ID generated in runtime by using generate_int_id() with
45+
min=MINIMUM_STATIC_PARTITION_ID and max=MYSQL_MAX_INT.
46+
- A (User) group is created for each user group in the course with the
47+
database user group ID as the group ID, and the user group name as the
48+
group name.
49+
- A user is assigned to a group if they are a member of the user group.
50+
"""
51+
52+
@classmethod
53+
def get_group_for_user(cls, course_key, user, user_partition):
54+
"""Get the (User) Group from the specified user partition for the user.
55+
56+
A user is assigned to the group via their user group membership and any
57+
mappings from user groups to partitions / groups that might exist.
58+
59+
Args:
60+
course_key (CourseKey): The course key.
61+
user (User): The user.
62+
user_partition (UserPartition): The user partition.
63+
64+
Returns:
65+
Group: The group in the specified user partition
66+
"""
67+
# TODO: A user could belong to multiple groups. This method assumes that
68+
# the user belongs to a single group. This should be renamed?
69+
if get_course_masquerade(user, course_key) and not is_masquerading_as_specific_student(user, course_key):
70+
return get_masquerading_user_group(course_key, user, user_partition)
71+
72+
user_group_ids = UserGroupMembership.objects.filter(user=user).values_list("group__id", flat=True)
73+
all_user_groups: list[UserGroup] = UserGroup.objects.all()
74+
75+
if not user_group_ids:
76+
return None
77+
78+
user_groups = []
79+
for user_group in all_user_groups:
80+
if user_group.id in user_group_ids:
81+
user_groups.append(Group(user_group.id, str(user_group.name)))
82+
83+
return user_groups
84+
85+
@classmethod
86+
def create_user_partition(cls, id, name, description, groups=None, parameters=None, active=True): # pylint: disable=redefined-builtin, invalid-name
87+
"""Create a custom UserPartition to support dynamic groups based on user groups.
88+
89+
A Partition has an id, name, scheme, description, parameters, and a
90+
list of groups. The id is intended to be unique within the context where
91+
these are used. (e.g., for partitions of users within a course, the ids
92+
should be unique per-course).
93+
94+
The scheme is used to assign users into groups. The parameters field is
95+
used to save extra parameters e.g., location of the course ID for this
96+
partition scheme.
97+
98+
Partitions can be marked as inactive by setting the "active" flag to False.
99+
Any group access rule referencing inactive partitions will be ignored
100+
when performing access checks.
101+
102+
Args:
103+
id (int): The id of the partition.
104+
name (str): The name of the partition.
105+
description (str): The description of the partition.
106+
groups (list of Group): The groups in the partition.
107+
parameters (dict): The parameters for the partition.
108+
active (bool): Whether the partition is active.
109+
110+
Returns:
111+
UserGroupPartition: The user partition.
112+
"""
113+
user_group_partition = UserGroupPartition(
114+
id,
115+
str(name),
116+
str(description),
117+
groups,
118+
cls,
119+
parameters,
120+
active=active,
121+
)
122+
123+
return user_group_partition
124+
125+
126+
def create_user_group_partition_with_course_id(course_id):
127+
"""
128+
Create and return the user group partition based only on course_id.
129+
If it cannot be created, None is returned.
130+
"""
131+
try:
132+
user_group_scheme = UserPartition.get_scheme(USER_GROUP_SCHEME)
133+
except UserPartitionError:
134+
log.warning(f"No {USER_GROUP_SCHEME} scheme registered, UserGroupPartition will not be created.")
135+
return None
136+
137+
partition = user_group_scheme.create_user_partition(
138+
id=USER_GROUP_PARTITION_ID,
139+
name=_("User Groups"),
140+
description=_("Partition for segmenting users by user groups"),
141+
parameters={"course_id": str(course_id)},
142+
)
143+
144+
return partition
145+
146+
147+
def create_user_group_partition(course):
148+
"""
149+
Get the dynamic enrollment track user partition based on the user groups of the course.
150+
"""
151+
return create_user_group_partition_with_course_id(course.id)

openedx_user_groups/processors/__init__.py

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

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)