Skip to content

Commit

Permalink
Merge branch 'master' into yusuf-musleh/collections-crud-rest-api
Browse files Browse the repository at this point in the history
  • Loading branch information
ChrisChV authored Sep 11, 2024
2 parents 1738447 + d59e2f4 commit b645044
Show file tree
Hide file tree
Showing 5 changed files with 204 additions and 19 deletions.
131 changes: 125 additions & 6 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
UNENROLLED_TO_ALLOWEDTOENROLL,
UNENROLLED_TO_ENROLLED,
UNENROLLED_TO_UNENROLLED,
CourseAccessRole,
CourseEnrollment,
CourseEnrollmentAllowed,
ManualEnrollmentAudit,
Expand All @@ -60,12 +61,14 @@
CourseFinanceAdminRole,
CourseInstructorRole,
)
from common.djangoapps.student.tests.factories import BetaTesterFactory
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory
from common.djangoapps.student.tests.factories import GlobalStaffFactory
from common.djangoapps.student.tests.factories import InstructorFactory
from common.djangoapps.student.tests.factories import StaffFactory
from common.djangoapps.student.tests.factories import UserFactory
from common.djangoapps.student.tests.factories import (
BetaTesterFactory,
CourseEnrollmentFactory,
GlobalStaffFactory,
InstructorFactory,
StaffFactory,
UserFactory
)
from lms.djangoapps.bulk_email.models import BulkEmailFlag, CourseEmail, CourseEmailTemplate
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import (
Expand Down Expand Up @@ -94,6 +97,9 @@
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.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
from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangoapps.user_api.preferences.api import delete_user_preference
Expand Down Expand Up @@ -4675,3 +4681,116 @@ def test_get_certificate_for_user_no_certificate(self):
f"The student {self.user} does not have certificate for the course {self.course.id.course}. Kindly "
"verify student username/email and the selected course are correct and try again."
)


@patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True})
class TestOauthInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test endpoints using Oauth2 authentication.
"""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create(
entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course')
)

def setUp(self):
super().setUp()

self.other_user = UserFactory()
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)

self.headers = {
'HTTP_AUTHORIZATION': 'JWT ' + jwt_token
}

# endpoints contains all urls with body and role.
self.endpoints = [
('list_course_role_members', {'rolename': 'staff'}, 'instructor'),
('register_and_enroll_students', {}, 'staff'),
('get_student_progress_url', {'course_id': str(self.course.id),
'unique_student_identifier': self.other_user.email
}, 'staff'
),
('list_entrance_exam_instructor_tasks', {'unique_student_identifier': self.other_user.email}, 'staff'),
('list_email_content', {}, 'staff'),
('show_student_extensions', {'student': self.other_user.email}, 'staff'),
('list_email_content', {}, 'staff'),
('list_report_downloads', {
"send-to": ["myself"],
"subject": "This is subject",
"message": "message"
}, 'data_researcher')
]

self.fake_jwt = ('wyJUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFuZ2UtbWUiLCJleHAiOjE3MjU4OTA2NzIsImdyY'
'W50X3R5cGUiOiJwYXNzd29yZCIsImlhdCI6MTcyNTg4NzA3MiwiaXNzIjoiaHR0cDovLzEyNy4wLjAuMTo4MDAwL29h'
'XNlcl9pZCI6MX0'
'.ec8neWp1YAuF40ye4oeK40obaapUvjfNPUQCycrsajwvcu58KcuLc96sf0JKmMMMn7DH9N98hg8W38iwbhKif1kLsCKr'
'tStl1u2XGvFkyMov8TtespbHit5LYRZpJwrhC1h50ru2buYj3isWrAElGPIDyAj0FAvSJnvJhWSMDtIwB2gxZI1DqOm'
'M6mzT7JbOU4QH2PNZrb2EZ11F6k9I-HrHnLQymr4s0vyjMlcBWllW3y19futNCgsFFRMXI4Z9zIbspsy5bq_Skub'
'dBpnl0P9x8vUJCAbFnJABAVPtF7F7nNsROQMKsZtQxaUUwdcYZi5qKL2GcgGfO0eTL4IbJA')

def assert_all_end_points(self, endpoint, body, role, add_role, use_jwt=True):
"""
Util method for verifying different end-points.
"""
if add_role:
role, _ = CourseAccessRole.objects.get_or_create(
course_id=self.course.id,
user=self.other_user,
role=role,
org=self.course.id.org
)

if use_jwt:
headers = self.headers
else:
headers = {
'HTTP_AUTHORIZATION': 'JWT ' + self.fake_jwt # this is fake jwt.
}

url = reverse(endpoint, kwargs={'course_id': str(self.course.id)})
response = self.client.post(
url,
data=body,
**headers
)
return response

def run_endpoint_tests(self, expected_status, add_role, use_jwt):
"""
Util method for running different end-points.
"""
for endpoint, body, role in self.endpoints:
with self.subTest(endpoint=endpoint, role=role, body=body):
response = self.assert_all_end_points(endpoint, body, role, add_role, use_jwt)
# JWT authentication works but it has no permissions.
assert response.status_code == expected_status, f"Failed for endpoint: {endpoint}"

def test_end_points_with_oauth_without_jwt(self):
"""
Verify the endpoint using invalid JWT returns 401.
"""
self.run_endpoint_tests(expected_status=401, add_role=False, use_jwt=False)

def test_end_points_with_oauth_without_permissions(self):
"""
Verify the endpoint using JWT authentication. But has no permissions.
"""
self.run_endpoint_tests(expected_status=403, add_role=False, use_jwt=True)

def test_end_points_with_oauth_with_permissions(self):
"""
Verify the endpoint using JWT authentication with permissions.
"""
self.run_endpoint_tests(expected_status=200, add_role=True, use_jwt=True)
2 changes: 1 addition & 1 deletion lms/djangoapps/instructor_task/tests/test_tasks_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ def test_query_counts(self):

with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'):
with check_mongo_calls(2):
with self.assertNumQueries(53):
with self.assertNumQueries(54):
CourseGradeReport.generate(None, None, course.id, {}, 'graded')

def test_inactive_enrollments(self):
Expand Down
5 changes: 5 additions & 0 deletions lms/djangoapps/verify_student/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,11 @@ class VerificationAttempt(TimeStampedModel):
blank=True,
)

@property
def updated_at(self):
"""Backwards compatibility with existing IDVerification models"""
return self.modified

@classmethod
def retire_user(cls, user_id):
"""
Expand Down
25 changes: 22 additions & 3 deletions lms/djangoapps/verify_student/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from lms.djangoapps.verify_student.utils import is_verification_expiring_soon
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers

from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification
from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt
from .utils import most_recent_verification

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -75,7 +75,8 @@ def verifications_for_user(cls, user):
Return a list of all verifications associated with the given user.
"""
verifications = []
for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'),
for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created'),
SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'),
SSOVerification.objects.filter(user=user).order_by('-created_at'),
ManualVerification.objects.filter(user=user).order_by('-created_at')):
verifications.append(verification)
Expand All @@ -92,6 +93,11 @@ def get_verified_user_ids(cls, users):
'created_at__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
}
return chain(
VerificationAttempt.objects.filter(**{
'user__in': users,
'status': 'approved',
'created__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"])
}).values_list('user_id', flat=True),
SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True),
SSOVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True),
ManualVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True)
Expand All @@ -117,11 +123,14 @@ def get_expiration_datetime(cls, user, statuses):
'status__in': statuses,
}

id_verifications = VerificationAttempt.objects.filter(**filter_kwargs)
photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs)
sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs)
manual_id_verifications = ManualVerification.objects.filter(**filter_kwargs)

attempt = most_recent_verification((photo_id_verifications, sso_id_verifications, manual_id_verifications))
attempt = most_recent_verification(
(photo_id_verifications, sso_id_verifications, manual_id_verifications, id_verifications)
)
return attempt and attempt.expiration_datetime

@classmethod
Expand Down Expand Up @@ -242,8 +251,18 @@ def get_verification_details_by_id(cls, attempt_id):
"""
Returns a verification attempt object by attempt_id
If the verification object cannot be found, returns None
This method does not take into account verifications stored in the
VerificationAttempt model used for pluggable IDV implementations.
As part of the work to implement pluggable IDV, this method's use
will be deprecated: https://openedx.atlassian.net/browse/OSPR-1011
"""
verification = None

# This does not look at the VerificationAttempt model since the provided id would become
# ambiguous between tables. The verification models in this list all inherit from the same
# base class and share the same id space.
verification_models = [
SoftwareSecurePhotoVerification,
SSOVerification,
Expand Down
60 changes: 51 additions & 9 deletions lms/djangoapps/verify_student/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
Tests for the service classes in verify_student.
"""

from datetime import datetime, timedelta, timezone
import itertools
from datetime import datetime, timedelta, timezone
from random import randint
from unittest.mock import patch

Expand All @@ -16,10 +16,16 @@
from pytz import utc

from common.djangoapps.student.tests.factories import UserFactory
from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification
from lms.djangoapps.verify_student.models import (
ManualVerification,
SoftwareSecurePhotoVerification,
SSOVerification,
VerificationAttempt
)
from lms.djangoapps.verify_student.services import IDVerificationService
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import \
ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order

FAKE_SETTINGS = {
Expand All @@ -34,12 +40,15 @@ class TestIDVerificationService(ModuleStoreTestCase):
Tests for IDVerificationService.
"""

def test_user_is_verified(self):
@ddt.data(
SoftwareSecurePhotoVerification, VerificationAttempt
)
def test_user_is_verified(self, verification_model):
"""
Test to make sure we correctly answer whether a user has been verified.
"""
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification(user=user)
attempt = verification_model(user=user)
attempt.save()

# If it's any of these, they're not verified...
Expand All @@ -49,16 +58,24 @@ def test_user_is_verified(self):
assert not IDVerificationService.user_is_verified(user), status

attempt.status = "approved"
if verification_model == VerificationAttempt:
attempt.expiration_datetime = now() + timedelta(days=19)
else:
attempt.expiration_date = now() + timedelta(days=19)
attempt.save()

assert IDVerificationService.user_is_verified(user), attempt.status

def test_user_has_valid_or_pending(self):
@ddt.data(
SoftwareSecurePhotoVerification, VerificationAttempt
)
def test_user_has_valid_or_pending(self, verification_model):
"""
Determine whether we have to prompt this user to verify, or if they've
already at least initiated a verification submission.
"""
user = UserFactory.create()
attempt = SoftwareSecurePhotoVerification(user=user)
attempt = verification_model(user=user)

# If it's any of these statuses, they don't have anything outstanding
for status in ["created", "ready", "denied"]:
Expand All @@ -70,6 +87,10 @@ def test_user_has_valid_or_pending(self):
# -- must_retry, and submitted both count until we hear otherwise
for status in ["submitted", "must_retry", "approved"]:
attempt.status = status
if verification_model == VerificationAttempt:
attempt.expiration_datetime = now() + timedelta(days=19)
else:
attempt.expiration_date = now() + timedelta(days=19)
attempt.save()
assert IDVerificationService.user_has_valid_or_pending(user), status

Expand Down Expand Up @@ -102,18 +123,22 @@ def test_get_verified_user_ids(self):
user_a = UserFactory.create()
user_b = UserFactory.create()
user_c = UserFactory.create()
user_d = UserFactory.create()
user_unverified = UserFactory.create()
user_denied = UserFactory.create()
user_denied_b = UserFactory.create()

SoftwareSecurePhotoVerification.objects.create(user=user_a, status='approved')
ManualVerification.objects.create(user=user_b, status='approved')
SSOVerification.objects.create(user=user_c, status='approved')
VerificationAttempt.objects.create(user=user_d, status='approved')
SSOVerification.objects.create(user=user_denied, status='denied')
VerificationAttempt.objects.create(user=user_denied_b, status='denied')

verified_user_ids = set(IDVerificationService.get_verified_user_ids([
user_a, user_b, user_c, user_unverified, user_denied
user_a, user_b, user_c, user_d, user_unverified, user_denied
]))
expected_user_ids = {user_a.id, user_b.id, user_c.id}
expected_user_ids = {user_a.id, user_b.id, user_c.id, user_d.id}

assert expected_user_ids == verified_user_ids

Expand Down Expand Up @@ -158,6 +183,23 @@ def test_get_expiration_datetime(self):
expiration_datetime = IDVerificationService.get_expiration_datetime(user_a, ['approved'])
assert expiration_datetime == newer_record.expiration_datetime

def test_get_expiration_datetime_mixed_models(self):
"""
Test that the latest expiration datetime is returned if there are both instances of
IDVerification models and VerificationAttempt models
"""
user = UserFactory.create()

SoftwareSecurePhotoVerification.objects.create(
user=user, status='approved', expiration_date=datetime(2021, 11, 12, 0, 0, tzinfo=timezone.utc)
)
newest = VerificationAttempt.objects.create(
user=user, status='approved', expiration_datetime=datetime(2022, 1, 12, 0, 0, tzinfo=timezone.utc)
)

expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved'])
assert expiration_datetime == newest.expiration_datetime

@ddt.data(
{'status': 'denied', 'error_msg': '[{"generalReasons": ["Name mismatch"]}]'},
{'status': 'approved', 'error_msg': ''},
Expand Down

0 comments on commit b645044

Please sign in to comment.