Skip to content

Commit

Permalink
feat: course_roles mfe-course authoring helper function (#33599)
Browse files Browse the repository at this point in the history
* feat: Roles 15 - permission checks back end changes part 1 (#33347)

* test: add test cases for permission list check functions

* test: update tests

* feat: add helper functions to check lists of permissions

* style: improve code style

* feat: add course roles checks in the contentstore app

* feat: add course roles checks in the student app

* feat: add course roles checks in the lms discussion app

* feat: add course roles checks in the lms instructor app

* feat: add course roles checks in the Learning Sequences package

* style: fix code style

* fix: course_permission_check calls

* feat: add validation for AnonymousUser in course permission check helper functions

* fix: disable some pylint warnings

* test: update number of querys asserted in has_course_author_access

* feat: add helper functions to check course or organization permissions

* test: update course_roles tests

* feat: replace course or organization helper functions in auth

* docs: update course_roles docstrings

* feat: ROLES-23 Create permissions in db table (#33394)

* feat: add migration to load permissions in the database

* feat: add Permission enum

* feat: change Permission enum name to CourseRolesPermission

* feat: replace permission constants with the CourseRolesPermission enum

* feat: add unique decorator to permissions enum

* feat: add course_roles_permissions dict with names and descriptions (with i18n)

* docs: add CourseRolesPermission docstring

* style: fix pylint errors

* style: fix pylint errors

* docs: add permissions module docstring

* feat: add helper function to get user permissions for a course

* test: add test for get_all_user_permissions_for_a_course

* feat: add views to coures roles api

* test: add test for course roles views

* feat: add urls for course roles api

* feat: add course roles api urls to lms and cms

* docs: add comment to indicate which urls are from course roles api

* feat: add translation to ValueError exception message

* feat: add translation to ValueError exception message

* feat: raise exeption if course does not exist

* feat: add instance permissions in get_all_user_permissions_for_a_course helper

* feat: improve validations in get_all_user_permissions_for_a_course

* feat: improve validations in UserPermissionsView

* test: update get user permissions tests

* docs: update UserPermissionsView docstring

* feat: change message errors

* docs: update docstrings in test_views

* fix: add missing super method call in a class

* fix: add password to test user

* fix: chain re-raising exceptions
  • Loading branch information
julianpalmerio authored and hsinkoff committed Nov 8, 2023
1 parent affa8f9 commit 4ac95de
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 2 deletions.
1 change: 1 addition & 0 deletions cms/djangoapps/contentstore/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def _course_team_user(request, course_key, email):
else:
role_hierarchy = (CourseInstructorRole, CourseStaffRole)

MANAGE_ALL_USERS_PERMISSION = "manage_all_users"
if request.method == "GET":
# just return info about the user
msg = {
Expand Down
5 changes: 5 additions & 0 deletions cms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,8 @@
re_path('^cms-api/ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
re_path('^cms-api/schema/', SpectacularAPIView.as_view(), name='schema'),
]

# Course Roles API
urlpatterns += [
path('api/course_roles/', include('openedx.core.djangoapps.course_roles.urls', namespace='course_roles_api')),
]
1 change: 1 addition & 0 deletions common/djangoapps/student/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ def get_user_permissions(user, course_key, org=None):
):
return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT
# Otherwise, for libraries, users can view only:
LIBRARY_USER_ROLE_PERMISSION = "view_library"
if course_key and isinstance(course_key, LibraryLocator):
# TODO: course roles: If the course roles feature flag is disabled the course_or_organization_permission_check
# call below will never return true.
Expand Down
1 change: 1 addition & 0 deletions lms/djangoapps/discussion/rest_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ def _format_datetime(dt):
course.get_discussion_blackout_datetimes()
)

MODEREATE_DISCUSSION_FORUM_PERMISSION = 'moderate_discussion_forum'
return {
"id": str(course_key),
"is_posting_enabled": is_posting_enabled,
Expand Down
5 changes: 5 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -1054,3 +1054,8 @@
urlpatterns += [
path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')),
]

# Course Roles API
urlpatterns += [
path('api/course_roles/', include('openedx.core.djangoapps.course_roles.urls', namespace='course_roles_api')),
]
41 changes: 40 additions & 1 deletion openedx/core/djangoapps/course_roles/helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""
Helpers for the course roles app.
"""
from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
from django.utils.translation import gettext as _

from openedx.core.djangoapps.course_roles.models import CourseRolesUserRole
from openedx.core.lib.cache_utils import request_cached
Expand Down Expand Up @@ -90,3 +91,41 @@ def course_or_organization_permission_list_check(user, permission_names, course_
course_or_organization_permission_check(user, permission_name, course_id, organization_name)
for permission_name in permission_names
)


def get_all_user_permissions_for_a_course(user_id, course_id):
"""
Get all of a user's permissions for a course,
including, if applicable, organization-level permissions
and instance-level permissions.
"""
if user_id is None or course_id is None:
raise ValueError(_('user_id and course_id must not be None'))
try:
user = User.objects.get(pk=user_id)
except User.DoesNotExist as exc:
raise ValueError(_('user does not exist')) from exc
try:
course = modulestore().get_course(course_id)
except AssertionError as exc:
raise ValueError(_('course_id is not valid')) from exc
if not course:
raise ValueError(_('course does not exist'))
course_permissions = set(CourseRolesUserRole.objects.filter(
user__id=user_id,
course=course_id,
).values_list('role__permissions__name', flat=True))
organization_name = course.org
organization_permissions = set(CourseRolesUserRole.objects.filter(
user__id=user_id,
course__isnull=True,
org__name=organization_name,
).values_list('role__permissions__name', flat=True))
permissions = course_permissions.union(organization_permissions)
instance_permissions = set(CourseRolesUserRole.objects.filter(
user__id=user_id,
course__isnull=True,
org__isnull=True,
).values_list('role__permissions__name', flat=True))
permissions = permissions.union(instance_permissions)
return permissions
107 changes: 106 additions & 1 deletion openedx/core/djangoapps/course_roles/tests/test_helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""
Tests of the course_roles.helpers module
"""
import ddt
from organizations.tests.factories import OrganizationFactory
import pytest

from common.djangoapps.student.tests.factories import AnonymousUserFactory, UserFactory
from openedx.core.djangoapps.course_roles.helpers import (
Expand All @@ -10,7 +12,8 @@
course_permission_check,
course_permissions_list_check,
organization_permission_check,
organization_permissions_list_check
organization_permissions_list_check,
get_all_user_permissions_for_a_course,
)
from openedx.core.djangoapps.course_roles.models import (
CourseRolesPermission,
Expand Down Expand Up @@ -453,3 +456,105 @@ def test_course_or_organization_list_permission_check_with_permission_in_another
assert not course_or_organization_permission_list_check(
self.user_1, test_permissions, self.course_1.id, self.organization_1.name
)


@ddt.ddt
class GetAllUserPermissionsTestcase(SharedModuleStoreTestCase):
"""
Tests of get_all_user_permissions_for_a_course function in course_roles.helpers module
"""

def setUp(self):
super().setUp()
self.user_1 = UserFactory(username="test_user_1")
self.user_2 = UserFactory(username="test_user_2")
self.organization_1 = OrganizationFactory(name="test_organization_1")
self.course_1 = CourseFactory.create(
display_name="test course 1", run="Testing_course_1", org=self.organization_1.name
)
self.role_1 = CourseRolesRole.objects.create(name="test_role_1")
self.role_2 = CourseRolesRole.objects.create(name="test_role_2")
self.role_3 = CourseRolesRole.objects.create(name="test_role_3")
self.role_4 = CourseRolesRole.objects.create(name="test_role_4")
self.role_5 = CourseRolesRole.objects.create(name="test_role_5")
self.service = CourseRolesService.objects.create(name="test_service")
self.role_1.services.add(self.service)
self.role_2.services.add(self.service)
self.role_3.services.add(self.service)
self.role_4.services.add(self.service)
self.role_5.services.add(self.service)
self.permission_1 = CourseRolesPermission.objects.create(name="test_permission_1")
self.permission_2 = CourseRolesPermission.objects.create(name="test_permission_2")
self.permission_3 = CourseRolesPermission.objects.create(name="test_permission_3")
self.permission_4 = CourseRolesPermission.objects.create(name="test_permission_4")
self.permission_5 = CourseRolesPermission.objects.create(name="test_permission_5")
self.role_1.permissions.add(self.permission_1)
self.role_2.permissions.add(self.permission_2)
self.role_3.permissions.add(self.permission_3)
self.role_4.permissions.add(self.permission_4)
self.role_5.permissions.add(self.permission_5)

def test_get_all_user_permissions_for_a_course(self):
"""
Test that get_all_user_permissions_for_a_course returns the correct permissions for the user and course
"""
CourseRolesUserRole.objects.create(
user=self.user_1, role=self.role_1, course_id=self.course_1.id, org=self.organization_1
)
CourseRolesUserRole.objects.create(
user=self.user_2, role=self.role_4, course_id=self.course_1.id, org=self.organization_1
)
# Test that the correct permissions are returned for user_1
assert get_all_user_permissions_for_a_course(self.user_1.id, self.course_1.id) == {self.permission_1.name}
CourseRolesUserRole.objects.create(
user=self.user_1, role=self.role_2, course_id=self.course_1.id, org=self.organization_1
)
# Test that the correct permissions are returned for user_1
assert get_all_user_permissions_for_a_course(self.user_1.id, self.course_1.id) == {
self.permission_1.name, self.permission_2.name}

CourseRolesUserRole.objects.create(
user=self.user_1, role=self.role_3, org=self.organization_1
)
# Test that the correct permissions are returned for user_1, including org level permissions
assert get_all_user_permissions_for_a_course(self.user_1.id, self.course_1.id) == {
self.permission_1.name, self.permission_2.name, self.permission_3.name}
CourseRolesUserRole.objects.create(
user=self.user_1, role=self.role_5
)
# Test that the correct permissions are returned for user_1, including instance level permissions
assert get_all_user_permissions_for_a_course(self.user_1.id, self.course_1.id) == {
self.permission_1.name, self.permission_2.name, self.permission_3.name, self.permission_5.name}

def test_get_all_user_permissions_for_a_course_with_no_permissions(self):
"""
Test that get_all_user_permissions_for_a_course returns an empty list when the user has no permissions
"""
assert not get_all_user_permissions_for_a_course(self.user_1.id, self.course_1.id)

@ddt.data(
(None, 'course_id'),
(1, None),
(None, None),
)
@ddt.unpack
def test_get_all_user_permissions_for_a_course_with_none_values(self, user_id, course_id):
"""
Test that get_all_user_permissions_for_a_course raises value error when the user has no permissions
"""
with pytest.raises(ValueError):
get_all_user_permissions_for_a_course(user_id, course_id)

def test_get_all_user_permissions_for_a_course_with_invalid_user(self):
"""
Test that get_all_user_permissions_for_a_course raises value error when the user not exist
"""
with pytest.raises(ValueError):
get_all_user_permissions_for_a_course(999, 999)

def test_get_all_user_permissions_for_a_course_with_invalid_course(self):
"""
Test that get_all_user_permissions_for_a_course raises value error when the course not exist
"""
with pytest.raises(ValueError):
get_all_user_permissions_for_a_course(self.user_1.id, 999)
94 changes: 94 additions & 0 deletions openedx/core/djangoapps/course_roles/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
Tests for the course_roles views.
"""
import ddt
from django.urls import reverse
from django.utils.http import urlencode
from rest_framework import status
from rest_framework.test import APIClient
from organizations.tests.factories import OrganizationFactory

from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.course_roles.models import (
CourseRolesPermission,
CourseRolesRole,
CourseRolesService,
CourseRolesUserRole,
)
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory


@ddt.ddt
class UserPermissionsViewTestCase(SharedModuleStoreTestCase):
"""
Tests for the UserPermissionsView.
"""
def setUp(self):
super().setUp()
self.client = APIClient()
self.user_1 = UserFactory(username="test_user_1", password="test")
self.organization_1 = OrganizationFactory(name="test_organization_1")
self.course_1 = CourseFactory.create(
display_name="test course 1", run="Testing_course_1", org=self.organization_1.name
)
self.role_1 = CourseRolesRole.objects.create(name="test_role_1")
self.service = CourseRolesService.objects.create(name="test_service")
self.role_1.services.add(self.service)
self.permission_1 = CourseRolesPermission.objects.create(name="test_permission_1")
self.permission_2 = CourseRolesPermission.objects.create(name="test_permission_2")
self.role_1.permissions.add(self.permission_1)
self.role_1.permissions.add(self.permission_2)
CourseRolesUserRole.objects.create(
user=self.user_1, role=self.role_1, course_id=self.course_1.id, org=self.organization_1
)

def test_get_user_permissions_without_login(self):
# Test get user permissions without login.
querykwargs = {'course_id': self.course_1.id, 'user_id': self.user_1.id}
url = f'{reverse("course_roles_api:user_permissions")}?{urlencode(querykwargs)}'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

def test_get_user_permissions_view(self):
# Test get user permissions view with valid queryargs.
querykwargs = {'course_id': self.course_1.id, 'user_id': self.user_1.id}
url = f'{reverse("course_roles_api:user_permissions")}?{urlencode(querykwargs)}'
expected_api_response = {'permissions': {self.permission_1.name, self.permission_2.name}}
# Ensure the view returns a 200 OK status code
self.client.login(username=self.user_1, password='test')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Ensure the view returns the correct permissions for the user
self.assertEqual(response.data['permissions'], expected_api_response['permissions'])

@ddt.data(
(None, None),
(None, "course_id"),
(1, None)
)
@ddt.unpack
def test_get_user_permission_view_without_queryargs(self, user_id, course_id):
# Test get user permissions without queryargs.
querykwargs = {'course_id': course_id, 'user_id': user_id}
querykwargs = {k: v for k, v in querykwargs.items() if v is not None}
url = f'{reverse("course_roles_api:user_permissions")}?{urlencode(querykwargs)}'
self.client.login(username=self.user_1, password='test')
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)

def test_get_user_permission_view_with_invalid_queryargs(self):
# Test get user permissions with invalid queryargs.
self.client.login(username=self.user_1, password='test')
org = 'org1'
number = 'course1'
run = 'run1'
course_id = self.store.make_course_key(org, number, run)
querykwargs = {'course_id': course_id, 'user_id': self.user_1.id}
url = f'{reverse("course_roles_api:user_permissions")}?{urlencode(querykwargs)}'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
querykwargs = {'course_id': self.course_1.id, 'user_id': 999}
url = f'{reverse("course_roles_api:user_permissions")}?{urlencode(querykwargs)}'
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
12 changes: 12 additions & 0 deletions openedx/core/djangoapps/course_roles/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
URL configuration for course roles api.
"""
from django.urls import path

from .views import UserPermissionsView

app_name = 'course_roles_api'

urlpatterns = [
path('v1/user_permissions/', UserPermissionsView.as_view(), name='user_permissions'),
]
50 changes: 50 additions & 0 deletions openedx/core/djangoapps/course_roles/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Views for the course roles API.
"""
from rest_framework.exceptions import ParseError, NotFound
from rest_framework.views import APIView
from rest_framework.response import Response
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey

from openedx.core.lib.api.view_utils import view_auth_classes
from openedx.core.djangoapps.course_roles.helpers import get_all_user_permissions_for_a_course


@view_auth_classes()
class UserPermissionsView(APIView):
"""
View for getting all permissions for a user in a course.
"""
def get(self, request):
"""
Get all permissions for a user in a course.
**Permissions**: User must be authenticated.
**Response Format**:
```json
{
"permissions": [(str) permission_name, ...]
}
```
**Response Error Codes**:
- 400: If the user_id or course_id parameters are missing or are invalid.
- 404: If the user or course does not exist.
"""
user_id = self.request.query_params.get('user_id', None)
if user_id is None:
raise ParseError('Required user_id parameter is missing')
course_id = self.request.query_params.get('course_id', None)
if course_id is None:
raise ParseError('Required course_id parameter is missing')
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError as exc:
raise ParseError('Invalid course_id parameter') from exc
try:
permissions = {
'permissions': get_all_user_permissions_for_a_course(user_id, course_key),
}
except ValueError as exc:
raise NotFound(str(exc)) from exc
return Response(permissions)

0 comments on commit 4ac95de

Please sign in to comment.