Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: list_forum_members-to-drf api to drf ( 14th ) #35366

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions lms/djangoapps/instructor/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
from bridgekeeper import perms
from bridgekeeper.rules import is_staff
from opaque_keys.edx.keys import CourseKey
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import BasePermission

from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.rules import HasAccessRule, HasRolesRule
from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR
from openedx.core.lib.courses import get_course_by_id

ALLOW_STUDENT_TO_BYPASS_ENTRANCE_EXAM = 'instructor.allow_student_to_bypass_entrance_exam'
Expand Down Expand Up @@ -82,3 +86,32 @@ def has_permission(self, request, view):
course = get_course_by_id(CourseKey.from_string(view.kwargs.get('course_id')))
permission = getattr(view, 'permission_name', None)
return request.user.has_perm(permission, course)


class ForumAdminRequiresInstructorAccess(BasePermission):
"""
default roles require either (staff & forum admin) or (instructor)
User should be forum-admin and staff to access this endpoint.

But if request rolename is FORUM_ROLE_ADMINISTRATOR, then user must also have
instructor-level access to proceed.
"""
def has_permission(self, request, view):
rolename = request.data.get('rolename')
course_id = view.kwargs.get('course_id')
course = get_course_by_id(CourseKey.from_string(course_id))

has_instructor_access = has_access(request.user, 'instructor', course)
has_forum_admin = has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
)

# default roles require either (staff & forum admin) or (instructor)
if not (has_forum_admin or has_instructor_access):
raise PermissionDenied("Operation requires staff & forum admin or instructor access")

# EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
raise PermissionDenied("Operation requires instructor access.")

return True
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of defaulting to True I think it's better to default to false and have a check for each of the combinations we're okay with. I'm having a little bit of trouble understanding how the two checks are different. If you're already checking to see if the user is a FORUM_ROLE_ADMINSTATOR do you care about whether or not that parameter is passed into the request?

139 changes: 125 additions & 14 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,12 @@
from django.test.client import MULTIPART_CONTENT
from django.urls import reverse as django_reverse
from django.utils.translation import gettext as _
from edx_when.api import get_dates_for_course, get_overrides_for_user, set_date_for_block
from edx_toggles.toggles.testutils import override_waffle_flag
from edx_when.api import get_dates_for_course, get_overrides_for_user, set_date_for_block
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locator import UsageKey
from pytz import UTC
from testfixtures import LogCapture
from xmodule.fields import Date
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase,
)
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory

from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
Expand All @@ -59,7 +53,7 @@
CourseBetaTesterRole,
CourseDataResearcherRole,
CourseFinanceAdminRole,
CourseInstructorRole,
CourseInstructorRole
)
from common.djangoapps.student.tests.factories import (
BetaTesterFactory,
Expand All @@ -71,9 +65,7 @@
)
from lms.djangoapps.bulk_email.models import BulkEmailFlag, CourseEmail, CourseEmailTemplate
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import (
GeneratedCertificateFactory
)
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
from lms.djangoapps.instructor.tests.utils import FakeContentTask, FakeEmail, FakeEmailInfo
Expand All @@ -95,7 +87,7 @@
from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory
from openedx.core.djangoapps.course_date_signals.handlers import extract_dates
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA
from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA, Role
from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles
from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api
from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter
Expand All @@ -106,6 +98,14 @@
from openedx.core.lib.teams_config import TeamsConfig
from openedx.core.lib.xblock_utils import grade_histogram
from openedx.features.course_experience import RELATIVE_DATES_FLAG
from xmodule.fields import Date
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_SPLIT_MODULESTORE,
ModuleStoreTestCase,
SharedModuleStoreTestCase
)
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory

from .test_tools import msk_from_problem_urlname

Expand Down Expand Up @@ -2250,6 +2250,7 @@ def setUp(self):
self.other_instructor = InstructorFactory(course_key=self.course.id)
self.other_staff = StaffFactory(course_key=self.course.id)
self.other_user = UserFactory()
self.list_forum_members_url = reverse('list_forum_members', kwargs={'course_id': str(self.course.id)})

def test_modify_access_noparams(self):
""" Test missing all query parameters. """
Expand Down Expand Up @@ -2446,8 +2447,11 @@ def test_update_forum_role_membership(self):

def assert_update_forum_role_membership(self, current_user, identifier, rolename, action):
"""
Test update forum role membership.
Get unique_student_identifier, rolename and action and update forum role.
default roles require either (staff & forum admin) or (instructor)
User should be forum-admin and staff to access this endpoint.

But if request rolename is FORUM_ROLE_ADMINISTRATOR, then user must also have
instructor-level access to proceed.
"""
url = reverse('update_forum_role_membership', kwargs={'course_id': str(self.course.id)})
response = self.client.post(
Expand Down Expand Up @@ -2481,6 +2485,113 @@ def test_autoenroll_on_forum_role_add(self):
self.assert_update_forum_role_membership(user, user.email, rolename, "revoke")
CourseEnrollment.unenroll(user, self.course.id)

def create_forum_roles(self, role_name, user):
"""
DRY Utility method for adding roles.
"""
role, __ = Role.objects.get_or_create(
course_id=self.course.id,
name=role_name
)
role.users.add(user)

def access_list_forum(self, user):
"""
Utility method for adding forums rules and hitting the url.
"""
for role_name in ["Group Moderator", "Moderator", "Community TA", "Administrator"]:
self.create_forum_roles(role_name, user)
return self.client.post(self.list_forum_members_url, {'rolename': role_name})

def test_staff_without_forum_admin_access(self):
"""
Test to ensure that an error is raised if the given rolename lacks the appropriate permissions.
Allowed Role is either (staff & forum admin) or (instructor).

In this test case user has staff permissions but his forum admin role is missing.
"""
self.client.logout()
self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)
role_name = "staff"
CourseAccessRoleFactory(
course_id=self.course.id,
user=self.other_user,
role=role_name,
org=self.course.id.org
)

response = self.access_list_forum(self.other_user)
assert response.status_code == 403

if role_name in ["Administrator"]:
# if the rolename is `Administrator` then user must need `Administrator` access.
assert (response.__dict__['data'].get('detail') ==
"Operation requires instructor access.")
else:
assert (response.__dict__['data'].get('detail') ==
"Operation requires staff & forum admin or instructor access")

def test_staff_with_forum_admin_access(self):
"""
Test to ensure that an error is raised if the given rolename lacks the appropriate permissions.
Allowed Role is either (staff & forum admin) or (instructor)

In this test case user has staff permissions and forum admin role also.
"""
self.client.logout()
self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD)

CourseAccessRoleFactory(
course_id=self.course.id,
user=self.other_user,
role="staff",
org=self.course.id.org
)

# make user staff and administrator
self.create_forum_roles('Administrator', self.other_user)
response = self.access_list_forum(self.other_user)
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert data['course_id'] == str(self.course.id)

def test_staff_with_forum_admin_access_with_oauth(self):
"""
Verify the endpoint using JWT authentication.
"""
self.client.logout()
dot_application = ApplicationFactory(user=self.other_user, authorization_grant_type='password')
access_token = AccessTokenFactory(user=self.other_user, application=dot_application)
oauth_adapter = DOTAdapter()
token_dict = {
'access_token': access_token,
'scope': 'email profile',
}
jwt_token = jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=True)
headers = {
'HTTP_AUTHORIZATION': 'JWT ' + jwt_token
}
response = self.client.post(
self.list_forum_members_url,
data={'rolename': 'Moderator'},
**headers
)
# JWT authentication works but it has no permissions.
assert response.status_code == 403

# add user as course staff.
CourseAccessRoleFactory(
course_id=self.course.id,
user=self.other_user,
role="staff",
org=self.course.id.org
)

# add user as forum admin.
self.create_forum_roles('Administrator', self.other_user)
response = self.client.post(self.list_forum_members_url, data={'rolename': 'Moderator'}, **headers)
assert response.status_code == 200


@ddt.ddt
class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
Expand Down
95 changes: 37 additions & 58 deletions lms/djangoapps/instructor/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,7 @@
from lms.djangoapps.courseware.access import has_access
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.courseware.models import StudentModule
from lms.djangoapps.discussion.django_comment_client.utils import (
get_group_id_for_user,
get_group_name,
has_forum_access,
)
from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access
from lms.djangoapps.instructor import enrollment
from lms.djangoapps.instructor.access import ROLES, allow_access, list_with_level, revoke_access, update_forum_role
from lms.djangoapps.instructor.constants import INVOICE_KEY
Expand All @@ -107,8 +103,8 @@
from lms.djangoapps.instructor_task.data import InstructorTaskTypes
from lms.djangoapps.instructor_task.models import ReportStore
from lms.djangoapps.instructor.views.serializer import (
AccessSerializer, BlockDueDateSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer,
SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer
AccessSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, ForumRoleNameSerializer,
BlockDueDateSerializer, SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer
)
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted
Expand Down Expand Up @@ -2733,12 +2729,8 @@ def problem_grade_report(request, course_id):
return JsonResponse({"status": success_status})


@require_POST
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_course_permission(permissions.VIEW_FORUM_MEMBERS)
@require_post_params('rolename')
def list_forum_members(request, course_id):
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
class ListForumMembers(APIView):
"""
Lists forum members of a certain rolename.
Limited to staff access.
Expand All @@ -2747,61 +2739,48 @@ def list_forum_members(request, course_id):
Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR
which is limited to instructors.

Takes query parameter `rolename`.
"""
course_id = CourseKey.from_string(course_id)
course = get_course_by_id(course_id)
has_instructor_access = has_access(request.user, 'instructor', course)
has_forum_admin = has_forum_access(
request.user, course_id, FORUM_ROLE_ADMINISTRATOR
permission_classes = (
IsAuthenticated, permissions.InstructorPermission, permissions.ForumAdminRequiresInstructorAccess
)
permission_name = permissions.VIEW_FORUM_MEMBERS
serializer_class = ForumRoleNameSerializer

rolename = request.POST.get('rolename')

# default roles require either (staff & forum admin) or (instructor)
if not (has_forum_admin or has_instructor_access):
return HttpResponseBadRequest(
"Operation requires staff & forum admin or instructor access"
)
@method_decorator(ensure_csrf_cookie)
def post(self, request, course_id):
"""
Handle the POST request to list forum members with a certain role name for the given course.

# EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor)
if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access:
return HttpResponseBadRequest("Operation requires instructor access.")
Args:
request (HttpRequest): The request object containing the data sent by the client.
course_id (int): The ID of the course for which the role is being assigned or managed.

# filter out unsupported for roles
if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_GROUP_MODERATOR,
FORUM_ROLE_COMMUNITY_TA]:
return HttpResponseBadRequest(strip_tags(
f"Unrecognized rolename '{rolename}'."
))
Returns:
Response: The Json constians lists of members.

try:
role = Role.objects.get(name=rolename, course_id=course_id)
users = role.users.all().order_by('username')
except Role.DoesNotExist:
users = []
Raises:
ValidationError: If the provided `rolename` is not valid according to the serializer.
"""
course_id = CourseKey.from_string(course_id)
course_discussion_settings = CourseDiscussionSettings.get(course_id)

course_discussion_settings = CourseDiscussionSettings.get(course_id)
role_serializer = ForumRoleNameSerializer(
data=request.data,
context={
'course_discussion_settings': course_discussion_settings,
'course_id': course_id
}
)

def extract_user_info(user):
""" Convert user to dict for json rendering. """
group_id = get_group_id_for_user(user, course_discussion_settings)
group_name = get_group_name(group_id, course_discussion_settings)
role_serializer.is_valid(raise_exception=True)
rolename = role_serializer.data['rolename']

return {
'username': user.username,
'email': user.email,
'first_name': user.first_name,
'last_name': user.last_name,
'group_name': group_name,
response_payload = {
'course_id': str(course_id),
rolename: role_serializer.data.get('users'),
'division_scheme': course_discussion_settings.division_scheme,
}

response_payload = {
'course_id': str(course_id),
rolename: list(map(extract_user_info, users)),
'division_scheme': course_discussion_settings.division_scheme,
}
return JsonResponse(response_payload)
return JsonResponse(response_payload)


@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
Expand Down
2 changes: 1 addition & 1 deletion lms/djangoapps/instructor/views/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
path('list_instructor_tasks', api.ListInstructorTasks.as_view(), name='list_instructor_tasks'),
path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'),
path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'),
path('list_forum_members', api.list_forum_members, name='list_forum_members'),
path('list_forum_members', api.ListForumMembers.as_view(), name='list_forum_members'),
path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'),
path('change_due_date', api.ChangeDueDate.as_view(), name='change_due_date'),
path('send_email', api.SendEmail.as_view(), name='send_email'),
Expand Down
Loading
Loading