Skip to content

Commit ef3f6f8

Browse files
committed
feat: implement content restriction based on new user groups
1 parent 13b5b89 commit ef3f6f8

5 files changed

Lines changed: 297 additions & 1 deletion

File tree

openedx_user_groups/partitions/__init__.py

Whitespace-only changes.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
14+
from xmodule.partitions.partitions import (
15+
Group,
16+
UserPartition, UserPartitionError
17+
)
18+
19+
20+
log = logging.getLogger(__name__)
21+
22+
23+
USER_GROUP_PARTITION_ID = 1000000000
24+
USER_GROUP_SCHEME = "user_group"
25+
DUMMY_USER_GROUPS = [
26+
{
27+
"id": 1,
28+
"name": "User Group A",
29+
"description": "First group for testing",
30+
},
31+
{
32+
"id": 2,
33+
"name": "User Group B",
34+
"description": "Second group for testing",
35+
},
36+
{
37+
"id": 3,
38+
"name": "User Group C",
39+
"description": "Third group for testing",
40+
},
41+
]
42+
43+
DUMMY_USER_GROUPS_MEMBERSHIP = {
44+
"admin": [2],
45+
"elon": [2, 3],
46+
"jeff": [1, 3],
47+
"marie": [1, 2, 3],
48+
}
49+
50+
51+
class UserGroupPartition(UserPartition):
52+
"""
53+
Extends UserPartition to support dynamic groups pulled from the new user
54+
groups system.
55+
"""
56+
57+
@property
58+
def groups(self):
59+
"""
60+
Dynamically generate groups (based on user groups) for this partition.
61+
"""
62+
return [
63+
Group(user_group["id"], str(user_group["name"]))
64+
for user_group in DUMMY_USER_GROUPS
65+
]
66+
67+
68+
class UserGroupPartitionScheme:
69+
"""Uses user groups to map learners into partition groups.
70+
71+
- A user partition is created for each user group in the course with a
72+
unused partition ID generated in runtime by using generate_int_id() with
73+
min=MINIMUM_STATIC_PARTITION_ID and max=MYSQL_MAX_INT.
74+
- A (User) group is created for each user group in the course with the
75+
database user group ID as the group ID, and the user group name as the
76+
group name.
77+
- A user is assigned to a group if they are a member of the user group.
78+
"""
79+
80+
@classmethod
81+
def get_group_for_user(cls, course_key, user, user_partition):
82+
"""Get the (User) Group from the specified user partition for the user.
83+
84+
A user is assigned to the group via their user group membership and any
85+
mappings from user groups to partitions / groups that might exist.
86+
87+
Args:
88+
course_key (CourseKey): The course key.
89+
user (User): The user.
90+
user_partition (UserPartition): The user partition.
91+
92+
Returns:
93+
Group: The group in the specified user partition
94+
"""
95+
# Un usuario podría pertenecer a multiples grupos. Este método asume que
96+
# el usuario pertenece a un solo grupo.
97+
if get_course_masquerade(
98+
user, course_key
99+
) and not is_masquerading_as_specific_student(user, course_key):
100+
return get_masquerading_user_group(course_key, user, user_partition)
101+
102+
user_groups_ids = DUMMY_USER_GROUPS_MEMBERSHIP[user.username]
103+
104+
if not user_groups_ids:
105+
return None
106+
107+
user_groups = []
108+
for user_group in DUMMY_USER_GROUPS:
109+
if user_group["id"] in user_groups_ids:
110+
user_groups.append(Group(user_group["id"], str(user_group["name"])))
111+
112+
return user_groups
113+
114+
@classmethod
115+
def create_user_partition(
116+
cls, id, name, description, groups=None, parameters=None, active=True
117+
): # pylint: disable=redefined-builtin, invalid-name
118+
"""Create a custom UserPartition to support dynamic groups based on user groups.
119+
120+
A Partition has an id, name, scheme, description, parameters, and a
121+
list of groups. The id is intended to be unique within the context where
122+
these are used. (e.g., for partitions of users within a course, the ids
123+
should be unique per-course).
124+
125+
The scheme is used to assign users into groups. The parameters field is
126+
used to save extra parameters e.g., location of the course ID for this
127+
partition scheme.
128+
129+
Partitions can be marked as inactive by setting the "active" flag to False.
130+
Any group access rule referencing inactive partitions will be ignored
131+
when performing access checks.
132+
133+
Args:
134+
id (int): The id of the partition.
135+
name (str): The name of the partition.
136+
description (str): The description of the partition.
137+
groups (list of Group): The groups in the partition.
138+
parameters (dict): The parameters for the partition.
139+
active (bool): Whether the partition is active.
140+
141+
Returns:
142+
UserGroupPartition: The user partition.
143+
"""
144+
user_group_partition = UserGroupPartition(
145+
id,
146+
str(name),
147+
str(description),
148+
groups,
149+
cls,
150+
parameters,
151+
active=active,
152+
)
153+
154+
return user_group_partition
155+
156+
157+
def create_user_group_partition_with_course_id(course_id):
158+
"""
159+
Create and return the user group partition based only on course_id.
160+
If it cannot be created, None is returned.
161+
"""
162+
try:
163+
user_group_scheme = UserPartition.get_scheme(USER_GROUP_SCHEME)
164+
except UserPartitionError:
165+
log.warning(
166+
f"No {USER_GROUP_SCHEME} scheme registered, UserGroupPartition will not be created."
167+
)
168+
return None
169+
170+
partition = user_group_scheme.create_user_partition(
171+
id=USER_GROUP_PARTITION_ID,
172+
name=_("User Groups"),
173+
description=_("Partition for segmenting users by user groups"),
174+
parameters={"course_id": str(course_id)},
175+
)
176+
177+
return partition
178+
179+
180+
def create_user_group_partition(course):
181+
"""
182+
Get the dynamic enrollment track user partition based on the user groups of the course.
183+
"""
184+
return create_user_group_partition_with_course_id(course.id)

openedx_user_groups/processors/__init__.py

Whitespace-only changes.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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
8+
9+
from openedx_user_groups.partitions.user_group_partition_scheme import create_user_group_partition_with_course_id, USER_GROUP_PARTITION_ID
10+
11+
from opaque_keys.edx.keys import CourseKey
12+
13+
from openedx.core import types
14+
from openedx.core.djangoapps.content.learning_sequences.api.processors.base import (
15+
OutlineProcessor,
16+
)
17+
18+
from xmodule.partitions.partitions import Group
19+
from xmodule.partitions.partitions_service import get_user_partition_groups
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):
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+
print(f"\n\nUser Partition Groups (User Group): {user_partition_groups}, Self: {self.user_groups}\n\n")
73+
74+
if not user_partition_groups:
75+
return False
76+
77+
groups = user_partition_groups.get(USER_GROUP_PARTITION_ID)
78+
if not groups:
79+
return False
80+
81+
for group in self.user_groups:
82+
if group.id in groups:
83+
return False
84+
85+
return True
86+
87+
def usage_keys_to_remove(self, full_course_outline):
88+
"""
89+
Content group exclusions remove the content entirely.
90+
91+
This method returns the usage keys of all content that should be
92+
removed from the course outline based on the user's team membership.
93+
In this context, a team within a team-set maps to a user partition group.
94+
"""
95+
removed_usage_keys = set()
96+
97+
for section in full_course_outline.sections:
98+
99+
remove_all_children = False
100+
101+
if self._is_user_excluded_by_partition_group(section.user_partition_groups):
102+
removed_usage_keys.add(section.usage_key)
103+
remove_all_children = True
104+
105+
for seq in section.sequences:
106+
if remove_all_children or self._is_user_excluded_by_partition_group(
107+
seq.user_partition_groups
108+
):
109+
removed_usage_keys.add(seq.usage_key)
110+
111+
return removed_usage_keys

setup.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def is_requirement(line):
161161
),
162162
include_package_data=True,
163163
install_requires=load_requirements("requirements/base.in"),
164-
python_requires=">=3.12",
164+
python_requires=">=3.11",
165165
license="AGPL 3.0",
166166
zip_safe=False,
167167
keywords="Python edx",
@@ -174,6 +174,7 @@ def is_requirement(line):
174174
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
175175
"Natural Language :: English",
176176
"Programming Language :: Python :: 3",
177+
"Programming Language :: Python :: 3.11",
177178
"Programming Language :: Python :: 3.12",
178179
],
179180
)

0 commit comments

Comments
 (0)