From 7a2a28cc8ad142d90a474b886ced2e3696a936c0 Mon Sep 17 00:00:00 2001 From: alangsto <46360176+alangsto@users.noreply.github.com> Date: Wed, 15 Jun 2022 16:19:09 -0400 Subject: [PATCH] feat: add patch endpoint to update exams (#16) --- edx_exams/apps/api/permissions.py | 9 + edx_exams/apps/api/serializers.py | 47 +++- edx_exams/apps/api/test_utils/__init__.py | 59 ++++ edx_exams/apps/api/test_utils/factories.py | 30 ++ edx_exams/apps/api/test_utils/mixins.py | 50 ++++ edx_exams/apps/api/urls.py | 2 +- edx_exams/apps/api/v1/tests/test_views.py | 258 ++++++++++++++++++ edx_exams/apps/api/v1/urls.py | 10 +- edx_exams/apps/api/v1/views.py | 182 +++++++++++- edx_exams/apps/core/constants.py | 9 + edx_exams/apps/core/exam_types.py | 52 ++++ .../migrations/0003_allow_null_provider.py | 24 ++ .../0004_alter_exam_unique_together.py | 17 ++ edx_exams/apps/core/models.py | 12 +- edx_exams/settings/test.py | 7 + requirements/base.txt | 2 +- requirements/dev.txt | 15 +- requirements/doc.txt | 17 +- requirements/pip.txt | 2 +- requirements/production.txt | 2 +- requirements/quality.txt | 15 +- requirements/test.in | 2 + requirements/test.txt | 13 +- requirements/validation.txt | 20 +- 24 files changed, 828 insertions(+), 28 deletions(-) create mode 100644 edx_exams/apps/api/permissions.py create mode 100644 edx_exams/apps/api/test_utils/__init__.py create mode 100644 edx_exams/apps/api/test_utils/factories.py create mode 100644 edx_exams/apps/api/test_utils/mixins.py create mode 100644 edx_exams/apps/api/v1/tests/test_views.py create mode 100644 edx_exams/apps/core/exam_types.py create mode 100644 edx_exams/apps/core/migrations/0003_allow_null_provider.py create mode 100644 edx_exams/apps/core/migrations/0004_alter_exam_unique_together.py diff --git a/edx_exams/apps/api/permissions.py b/edx_exams/apps/api/permissions.py new file mode 100644 index 00000000..b7fd9dea --- /dev/null +++ b/edx_exams/apps/api/permissions.py @@ -0,0 +1,9 @@ +""" Permissions for edx-exams API""" +from rest_framework.permissions import BasePermission + + +class StaffUserPermissions(BasePermission): + """ Permission class to check if user is staff """ + + def has_permission(self, request, view): + return request.user.is_staff diff --git a/edx_exams/apps/api/serializers.py b/edx_exams/apps/api/serializers.py index 69067505..bf050a1c 100644 --- a/edx_exams/apps/api/serializers.py +++ b/edx_exams/apps/api/serializers.py @@ -1,4 +1,43 @@ -# Serializers that can be shared across multiple versions of the API -# should be created here. As the API evolves, serializers may become more -# specific to a particular version of the API. In this case, the serializers -# in question should be moved to versioned sub-package. +""" +Serializers for the edx-exams API +""" +from rest_framework import serializers + +from edx_exams.apps.core.exam_types import EXAM_TYPES +from edx_exams.apps.core.models import Exam + + +class ExamSerializer(serializers.ModelSerializer): + """ + Serializer for the Exam Model + """ + + exam_name = serializers.CharField(required=True) + course_id = serializers.CharField(required=False) + content_id = serializers.CharField(required=True) + time_limit_mins = serializers.IntegerField(required=True) + due_date = serializers.DateTimeField(required=False, format=None) + exam_type = serializers.CharField(required=True) + hide_after_due = serializers.BooleanField(required=True) + is_active = serializers.BooleanField(required=True) + + class Meta: + """ + Meta Class + """ + + model = Exam + + fields = ( + "id", "exam_name", "course_id", "content_id", "time_limit_mins", "due_date", "exam_type", + "hide_after_due", "is_active", + ) + + def validate_exam_type(self, value): + """ + Validate that exam_type is one of the predefined choices + """ + valid_exam_types = [exam_type.name for exam_type in EXAM_TYPES] + if value not in valid_exam_types: + raise serializers.ValidationError("Must be a valid exam type.") + return value diff --git a/edx_exams/apps/api/test_utils/__init__.py b/edx_exams/apps/api/test_utils/__init__.py new file mode 100644 index 00000000..d93ad9c8 --- /dev/null +++ b/edx_exams/apps/api/test_utils/__init__.py @@ -0,0 +1,59 @@ +""" +Test Utilities +""" + +from rest_framework.test import APIClient, APITestCase + +from edx_exams.apps.api.test_utils.factories import UserFactory +from edx_exams.apps.api.test_utils.mixins import JwtMixin +from edx_exams.apps.core.models import ProctoringProvider + +TEST_USERNAME = 'api_worker' +TEST_EMAIL = 'test@email.com' +TEST_PASSWORD = 'QWERTY' + + +class ExamsAPITestCase(JwtMixin, APITestCase): + """ + Base class for API Tests + """ + + def setUp(self): + """ + Perform operations common to all tests. + """ + super().setUp() + self.create_user(username=TEST_USERNAME, email=TEST_EMAIL, password=TEST_PASSWORD, is_staff=True) + self.client = APIClient() + self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD) + + self.test_provider = ProctoringProvider.objects.create( + name='test_provider', + verbose_name='testing_provider', + lti_configuration_id='123456789' + ) + + def tearDown(self): + """ + Perform common tear down operations to all tests. + """ + # Remove client authentication credentials + self.client.logout() + super().tearDown() + + def create_user(self, username=TEST_USERNAME, password=TEST_PASSWORD, is_staff=False, **kwargs): + """ + Create a test user and set its password. + """ + self.user = UserFactory(username=username, is_active=True, is_staff=is_staff, **kwargs) + self.user.set_password(password) + self.user.save() + + def build_jwt_headers(self, user): + """ + Set jwt token in cookies. + """ + jwt_payload = self.default_payload(user) + jwt_token = self.generate_token(jwt_payload) + headers = {"HTTP_AUTHORIZATION": "JWT " + jwt_token} + return headers diff --git a/edx_exams/apps/api/test_utils/factories.py b/edx_exams/apps/api/test_utils/factories.py new file mode 100644 index 00000000..2ca98df1 --- /dev/null +++ b/edx_exams/apps/api/test_utils/factories.py @@ -0,0 +1,30 @@ +""" +Factories for exams tests +""" + +import factory +from django.contrib.auth import get_user_model +from factory.django import DjangoModelFactory + + +class UserFactory(DjangoModelFactory): + """ + Factory to create users to be used in other unit tests + """ + + class Meta: + model = get_user_model() + django_get_or_create = ( + "email", + "username", + ) + + _DEFAULT_PASSWORD = "test" + + username = factory.Sequence("user{}".format) + email = factory.Sequence("user+test+{}@edx.org".format) + password = factory.PostGenerationMethodCall("set_password", _DEFAULT_PASSWORD) + first_name = factory.Sequence("User{}".format) + last_name = "Test" + is_superuser = False + is_staff = False diff --git a/edx_exams/apps/api/test_utils/mixins.py b/edx_exams/apps/api/test_utils/mixins.py new file mode 100644 index 00000000..3e2ed0db --- /dev/null +++ b/edx_exams/apps/api/test_utils/mixins.py @@ -0,0 +1,50 @@ +""" +Mixins for edx-exams API tests. +""" +from time import time + +import jwt +from django.conf import settings + +JWT_AUTH = "JWT_AUTH" + + +class JwtMixin: + """ + Mixin with JWT-related helper functions + """ + + JWT_SECRET_KEY = getattr(settings, JWT_AUTH)["JWT_SECRET_KEY"] + JWT_ISSUER = getattr(settings, JWT_AUTH)["JWT_ISSUER"] + JWT_AUDIENCE = getattr(settings, JWT_AUTH)["JWT_AUDIENCE"] + + def generate_token(self, payload, secret=None): + """ + Generate a JWT token with the provided payload + """ + secret = secret or self.JWT_SECRET_KEY + token = jwt.encode(payload, secret) + return token + + def default_payload(self, user, ttl=1): + """ + Generate a bare payload, in case tests need to manipulate + it directly before encoding + """ + now = int(time()) + + return { + "iss": self.JWT_ISSUER, + "sub": user.pk, + "aud": self.JWT_AUDIENCE, + "nonce": "dummy-nonce", + "exp": now + ttl, + "iat": now, + "preferred_username": user.username, + "administrator": user.is_staff, + "email": user.email, + "locale": "en", + "name": user.full_name, + "given_name": "", + "family_name": "", + } diff --git a/edx_exams/apps/api/urls.py b/edx_exams/apps/api/urls.py index 68a58223..bac91ecb 100644 --- a/edx_exams/apps/api/urls.py +++ b/edx_exams/apps/api/urls.py @@ -10,5 +10,5 @@ app_name = 'api' urlpatterns = [ - path(r'^v1/', include(v1_urls)), + path('v1/', include(v1_urls)), ] diff --git a/edx_exams/apps/api/v1/tests/test_views.py b/edx_exams/apps/api/v1/tests/test_views.py new file mode 100644 index 00000000..e371d501 --- /dev/null +++ b/edx_exams/apps/api/v1/tests/test_views.py @@ -0,0 +1,258 @@ +""" +Tests for the exams API views +""" +import json +import uuid +from datetime import datetime + +import ddt +import pytz +from django.urls import reverse + +from edx_exams.apps.api.test_utils import ExamsAPITestCase +from edx_exams.apps.api.test_utils.factories import UserFactory +from edx_exams.apps.core.models import CourseExamConfiguration, Exam + + +@ddt.ddt +class CourseExamsViewTests(ExamsAPITestCase): + """ + Tests CourseExamsView + """ + + def setUp(self): + super().setUp() + + self.course_id = 'course-v1:edx+test+f19' + self.content_id = '11111111' + + self.course_exam_config = CourseExamConfiguration.objects.create( + course_id=self.course_id, + provider=self.test_provider, + allow_opt_out=False + ) + + self.exam = Exam.objects.create( + resource_id=str(uuid.uuid4()), + course_id=self.course_id, + provider=self.test_provider, + content_id=self.content_id, + exam_name='test_exam', + exam_type='proctored', + time_limit_mins=30, + due_date='2021-07-01 00:00:00', + hide_after_due=False, + is_active=True + ) + + self.url = reverse('api:v1:exams-course_exams', kwargs={'course_id': self.course_id}) + + def patch_api(self, user, data): + """ + Helper function to make a patch request to the API + """ + + data = json.dumps(data) + headers = self.build_jwt_headers(user) + + return self.client.patch(self.url, data, **headers, content_type="application/json") + + def get_response(self, user, data, expected_response): + """ + Helper function to get API response + """ + response = self.patch_api(user, data) + self.assertEqual(response.status_code, expected_response) + + return response + + def test_auth_failures(self): + """ + Verify the endpoint validates permissions + """ + + # Test unauthenticated + response = self.client.patch(self.url) + self.assertEqual(response.status_code, 401) + + # Test non-staff worker + random_user = UserFactory() + self.get_response(random_user, [], 403) + + def test_exam_empty_exam_list(self): + """ + Test that exams not included in request are marked as inactive + """ + course_exams = Exam.objects.filter(course_id=self.course_id) + self.assertEqual(len(course_exams.filter(is_active=True)), 1) + self.assertEqual(len(course_exams.filter(is_active=False)), 0) + + self.get_response(self.user, [], 200) + + course_exams = Exam.objects.filter(course_id=self.course_id) + self.assertEqual(len(course_exams.filter(is_active=True)), 0) + self.assertEqual(len(course_exams.filter(is_active=False)), 1) + + def test_invalid_data(self): + """ + Assert that endpoint returns 400 if data does not pass serializer validation + """ + data = [ + { + 'content_id': '22222222', + 'exam_name': 'Test Exam 2', + 'exam_type': 'timed', + 'time_limit_mins': 30, + 'due_date': '2025-07-01 00:00:00', + 'hide_after_due': 'xxxx', + 'is_active': 'yyyy', + } + ] + response = self.get_response(self.user, data, 400) + self.assertIn("hide_after_due", response.data["errors"][0]) + self.assertIn("is_active", response.data["errors"][0]) + + def test_invalid_exam_type(self): + """ + Test that endpoint returns 400 if exam type is invalid + """ + data = [ + { + 'content_id': '22222222', + 'exam_name': 'Test Exam 2', + 'exam_type': 'something_bad', + 'time_limit_mins': 30, + 'due_date': '2025-07-01 00:00:00', + 'hide_after_due': False, + 'is_active': True, + } + ] + response = self.get_response(self.user, data, 400) + self.assertIn("exam_type", response.data["errors"][0]) + + def test_existing_exam_update(self): + """ + Test that an exam can be updated if it already exists + """ + + data = [ + { + 'content_id': self.exam.content_id, + 'exam_name': 'Something Different', + 'exam_type': self.exam.exam_type, # exam type differs from existing exam + 'time_limit_mins': 45, + 'due_date': self.exam.due_date, + 'hide_after_due': self.exam.hide_after_due, + 'is_active': self.exam.is_active, + } + ] + self.get_response(self.user, data, 200) + + exam = Exam.objects.get(course_id=self.course_id, content_id=self.content_id) + self.assertEqual(exam.exam_name, 'Something Different') + self.assertEqual(exam.provider, self.exam.provider) + self.assertEqual(exam.time_limit_mins, 45) + self.assertEqual(exam.due_date, pytz.utc.localize(datetime.fromisoformat(self.exam.due_date))) + self.assertEqual(exam.hide_after_due, self.exam.hide_after_due) + self.assertEqual(exam.is_active, self.exam.is_active) + + def test_exam_modified_type(self): + """ + Test that when updating an exam to a different exam_type, the pre-existing exam + is marked as inactive, and a new exam is created + """ + data = [ + { + 'content_id': self.exam.content_id, + 'exam_name': self.exam.exam_name, + 'exam_type': 'timed', # exam type differs from existing exam + 'time_limit_mins': 30, + 'due_date': self.exam.due_date, + 'hide_after_due': self.exam.hide_after_due, + 'is_active': True, + } + ] + self.get_response(self.user, data, 200) + + # check that proctored exam has been marked as inactive + proctored_exam = Exam.objects.get(course_id=self.course_id, content_id=self.content_id, exam_type='proctored') + self.assertFalse(proctored_exam.is_active) + + # check that timed exam has been created + timed_exam = Exam.objects.get(course_id=self.course_id, content_id=self.content_id, exam_type='timed') + self.assertEqual(timed_exam.exam_name, self.exam.exam_name) + self.assertEqual(timed_exam.provider, None) + self.assertEqual(timed_exam.time_limit_mins, 30) + self.assertEqual(timed_exam.due_date, pytz.utc.localize(datetime.fromisoformat(self.exam.due_date))) + self.assertEqual(timed_exam.hide_after_due, self.exam.hide_after_due) + self.assertEqual(timed_exam.is_active, True) + + # modify same exam back to proctored + data = [ + { + 'content_id': self.exam.content_id, + 'exam_name': self.exam.exam_name, + 'exam_type': 'proctored', # exam type differs from existing exam + 'time_limit_mins': 30, + 'due_date': self.exam.due_date, + 'hide_after_due': self.exam.hide_after_due, + 'is_active': True, + } + ] + self.get_response(self.user, data, 200) + + # check that only two exams still exist (should not create a third) + exams = Exam.objects.filter(course_id=self.course_id, content_id=self.content_id) + self.assertEqual(len(exams), 2) + + # check that timed exam is marked inactive + timed_exam = Exam.objects.get(course_id=self.course_id, content_id=self.content_id, exam_type='timed') + self.assertFalse(timed_exam.is_active) + + # check that proctored exam data is correct + proctored_exam = Exam.objects.get(course_id=self.course_id, content_id=self.content_id, exam_type='proctored') + self.assertEqual(proctored_exam.exam_name, self.exam.exam_name) + self.assertEqual(proctored_exam.provider, self.exam.provider) + self.assertEqual(proctored_exam.time_limit_mins, 30) + self.assertEqual(proctored_exam.due_date, pytz.utc.localize(datetime.fromisoformat(self.exam.due_date))) + self.assertEqual(proctored_exam.hide_after_due, self.exam.hide_after_due) + self.assertEqual(proctored_exam.is_active, True) + + @ddt.data( + (True, 'proctored', True), # test case for a proctored exam with no course config + (False, 'proctored', False), # test case for a proctored exam with a course config + (False, 'timed', True), # test case for a timed exam with a course config + (True, 'timed', True) # test case for a timed exam with no course config + ) + @ddt.unpack + def test_exams_config(self, other_course_id, exam_type, expect_none_provider): + """ + Test that the correct provider is set for a new exam based on the course's exam config + """ + course_id = 'courses-v1:edx+testing2+2022' if other_course_id else self.course_id + provider = None if expect_none_provider else self.test_provider + + data = [ + { + 'content_id': '22222222', + 'exam_name': 'Test Exam 2', + 'exam_type': exam_type, + 'time_limit_mins': 30, + 'due_date': '2025-07-01 00:00:00', + 'hide_after_due': False, + 'is_active': True, + } + ] + + self.url = reverse('api:v1:exams-course_exams', kwargs={'course_id': course_id}) + self.get_response(self.user, data, 200) + + self.assertEqual(len(Exam.objects.filter(course_id=self.course_id)), 1 if other_course_id else 2) + new_exam = Exam.objects.get(content_id='22222222') + self.assertEqual(new_exam.exam_name, 'Test Exam 2') + self.assertEqual(new_exam.exam_type, exam_type) + self.assertEqual(new_exam.provider, provider) + self.assertEqual(new_exam.time_limit_mins, 30) + self.assertEqual(new_exam.due_date, pytz.utc.localize(datetime.fromisoformat('2025-07-01 00:00:00'))) + self.assertEqual(new_exam.hide_after_due, False) + self.assertEqual(new_exam.is_active, True) diff --git a/edx_exams/apps/api/v1/urls.py b/edx_exams/apps/api/v1/urls.py index 8f654b2c..6a3303f5 100644 --- a/edx_exams/apps/api/v1/urls.py +++ b/edx_exams/apps/api/v1/urls.py @@ -1,4 +1,12 @@ """ API v1 URLs. """ +from django.urls import re_path + +from edx_exams.apps.api.v1.views import CourseExamsView +from edx_exams.apps.core.constants import COURSE_ID_PATTERN + app_name = 'v1' -urlpatterns = [] + +urlpatterns = [ + re_path(fr'exams/course_id/{COURSE_ID_PATTERN}', CourseExamsView.as_view(), name='exams-course_exams'), +] diff --git a/edx_exams/apps/api/v1/views.py b/edx_exams/apps/api/v1/views.py index 60f00ef0..08be46df 100644 --- a/edx_exams/apps/api/v1/views.py +++ b/edx_exams/apps/api/v1/views.py @@ -1 +1,181 @@ -# Create your views here. +""" +V1 API Views +""" +import logging +import uuid + +from edx_api_doc_tools import path_parameter, schema +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from edx_exams.apps.api.permissions import StaffUserPermissions +from edx_exams.apps.api.serializers import ExamSerializer +from edx_exams.apps.core.exam_types import get_exam_type +from edx_exams.apps.core.models import CourseExamConfiguration, Exam + +log = logging.getLogger(__name__) + + +class CourseExamsView(APIView): + """ + View to modify exams for a specific course. + + Given a list of exam data for a course, this view will either create a new exam (if one doesn't exist), or modify + an existing exam. Any course exams missing from the request will be marked as inactive. + + HTTP PATCH + Creates a new Exam. + Expected PATCH data: [{ + "content_id": 123, + "exam_name": "Test Examination", + "time_limit_mins": 30, + "due_date": "2021-08-04", + "exam_type": "proctored", + "hide_after_due": False, + "is_active": True + }] + **PATCH data Parameters** + * content_id: This will be the pointer to the id of the piece of course_ware which is the proctored exam. + * exam_name: This is the display name of the Exam (Midterm etc). + * time_limit_mins: Time limit (in minutes) that a student can finish this exam. + * due_date: The date on which the exam is due + * exam_type: Type of exam, i.e. timed, proctored + * hide_after_due: Whether the exam will be hidden from the learner after the due date has passed. + * is_active: Whether this exam will be active. + **Exceptions** + * HTTP_400_BAD_REQUEST + """ + + authentication_classes = (JwtAuthentication,) + permission_classes = (StaffUserPermissions,) + + @classmethod + def update_exam(cls, exam_object, fields): + """ + Given an exam object, update to the given fields value + """ + for attr, value in fields.items(): + setattr(exam_object, attr, value) + exam_object.save() + + log.info( + "Updated existing exam=%(exam_id)s", + { + 'exam_id': exam_object.id, + } + ) + + @classmethod + def create_exam(cls, fields): + """ + Create a new exam based on the given fields + """ + exam = Exam.objects.create(resource_id=str(uuid.uuid4()), **fields) + + log.info( + "Created new exam=%(exam_id)s", + { + 'exam_id': exam.id, + } + ) + + @classmethod + def handle_exams(cls, request_exams_list, course_exams_qs, course_id): + """ + Decide how exams should be updated or created + """ + exams_by_content_id = {} + for exam in course_exams_qs: + type_dict = exams_by_content_id.get(exam.content_id) + if not type_dict: + exams_by_content_id[exam.content_id] = {} + + exams_by_content_id[exam.content_id][exam.exam_type] = exam + + for exam in request_exams_list: + # should only be one object per exam type per content_id + + existing_type_exam = exams_by_content_id.get(exam['content_id'], {}).get(exam['exam_type']) + + if existing_type_exam: + # if the existing exam of the same type is not active, + # mark all other exams for this content id as inactive + if not existing_type_exam.is_active: + course_exams_qs.filter(content_id=exam['content_id']).update(is_active=False) + + # then update the existing exam + update_fields = { + 'exam_name': exam['exam_name'], + 'time_limit_mins': exam['time_limit_mins'], + 'due_date': exam['due_date'], + 'hide_after_due': exam['hide_after_due'], + 'is_active': exam['is_active'], + } + cls.update_exam(existing_type_exam, update_fields) + else: + # if existing exam with the type we receive does not exist, mark all other exams inactive + course_exams_qs.filter(content_id=exam['content_id']).update(is_active=False) + + provider = None + # get exam type class, which has specific attributes like is_proctored, is_timed, etc. + exam_type_class = get_exam_type(exam['exam_type']) + config = CourseExamConfiguration.objects.filter(course_id=course_id).first() + + # if exam type requires proctoring and the course has a config, use the configured provider + if exam_type_class and exam_type_class.is_proctored and config: + provider = config.provider + + # then create a new exam based on data we received + exam_fields = { + 'course_id': course_id, + 'provider': provider, + 'content_id': exam['content_id'], + 'exam_name': exam['exam_name'], + 'exam_type': exam['exam_type'], + 'time_limit_mins': exam['time_limit_mins'], + 'due_date': exam['due_date'], + 'hide_after_due': exam['hide_after_due'], + 'is_active': exam['is_active'], + } + cls.create_exam(exam_fields) + + @schema( + body=ExamSerializer(many=True), + parameters=[ + path_parameter('course_id', str, 'edX course run ID or external course key'), + ], + responses={ + 200: "OK", + 400: "Invalid request. See message." + }, + summary='Modify exams', + description='This endpoint should create new exams, update existing exams, ' + 'and mark any active exams not included in the payload as inactive.' + ) + def patch(self, request, course_id): + """ + Create or update a list of exams. + """ + request_exams = request.data + + serializer = ExamSerializer(data=request_exams, many=True) + + if serializer.is_valid(): + course_exams = Exam.objects.filter(course_id=course_id) + + # decide how to update or create exams based on the request and already existing exams + self.handle_exams(request_exams, course_exams, course_id) + + # mark any exams not included in the request as inactive. The Query set has already been filtered by course + remaining_exams = course_exams.exclude(content_id__in=[exam['content_id'] for exam in request_exams]) + remaining_exams.update(is_active=False) + + response_status = status.HTTP_200_OK + data = {} + else: + response_status = status.HTTP_400_BAD_REQUEST + data = {"detail": "Invalid data", "errors": serializer.errors} + + return Response(status=response_status, data=data) diff --git a/edx_exams/apps/core/constants.py b/edx_exams/apps/core/constants.py index 8a969c71..00ebe761 100644 --- a/edx_exams/apps/core/constants.py +++ b/edx_exams/apps/core/constants.py @@ -5,3 +5,12 @@ class Status: """Health statuses.""" OK = "OK" UNAVAILABLE = "UNAVAILABLE" + + +# Pulled from edx-platform. Will correctly capture both old- and new-style +# course ID strings. +INTERNAL_COURSE_KEY_PATTERN = r'([^/+]+(/|\+)[^/+]+(/|\+)[^/?]+)' + +EXTERNAL_COURSE_KEY_PATTERN = r'([A-Za-z0-9-_:]+)' + +COURSE_ID_PATTERN = rf'(?P({INTERNAL_COURSE_KEY_PATTERN}|{EXTERNAL_COURSE_KEY_PATTERN}))' diff --git a/edx_exams/apps/core/exam_types.py b/edx_exams/apps/core/exam_types.py new file mode 100644 index 00000000..d3da66ef --- /dev/null +++ b/edx_exams/apps/core/exam_types.py @@ -0,0 +1,52 @@ +""" +This module defines a set of exam types +""" + + +class ExamType: + """ + A collection of properties that describe a specific type of exam + """ + name = None + is_proctored = False + is_timed = False + is_practice = False + + +class ProctoredExamType(ExamType): + """ + Properties for a proctored exam + """ + name = 'proctored' + description = 'Non-practice, timed, proctored exam' + is_proctored = True + is_timed = True + is_practice = False + + +class TimedExamType(ExamType): + """ + Properties for a timed exam + """ + name = 'timed' + description = 'Non-practice, non-proctored, timed exam' + is_proctored = False + is_timed = True + is_practice = False + + +EXAM_TYPES = [ + ProctoredExamType, + TimedExamType +] + + +def get_exam_type(name): + """ + Return the correct class based on a given exam type name + """ + for exam_type in EXAM_TYPES: + if name == exam_type.name: + return exam_type + + return None diff --git a/edx_exams/apps/core/migrations/0003_allow_null_provider.py b/edx_exams/apps/core/migrations/0003_allow_null_provider.py new file mode 100644 index 00000000..8f2309e1 --- /dev/null +++ b/edx_exams/apps/core/migrations/0003_allow_null_provider.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.13 on 2022-06-14 15:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_create_exam_models'), + ] + + operations = [ + migrations.AlterField( + model_name='exam', + name='exam_type', + field=models.CharField(choices=[('proctored', 'Non-practice, timed, proctored exam'), ('timed', 'Non-practice, non-proctored, timed exam')], db_index=True, max_length=255), + ), + migrations.AlterField( + model_name='exam', + name='provider', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='core.proctoringprovider'), + ), + ] diff --git a/edx_exams/apps/core/migrations/0004_alter_exam_unique_together.py b/edx_exams/apps/core/migrations/0004_alter_exam_unique_together.py new file mode 100644 index 00000000..3d93727b --- /dev/null +++ b/edx_exams/apps/core/migrations/0004_alter_exam_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.13 on 2022-06-15 17:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_allow_null_provider'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='exam', + unique_together={('course_id', 'content_id', 'exam_type', 'provider')}, + ), + ] diff --git a/edx_exams/apps/core/models.py b/edx_exams/apps/core/models.py index da731406..21396123 100644 --- a/edx_exams/apps/core/models.py +++ b/edx_exams/apps/core/models.py @@ -5,6 +5,8 @@ from django.utils.translation import gettext_lazy as _ from model_utils.models import TimeStampedModel +from edx_exams.apps.core.exam_types import EXAM_TYPES + class User(AbstractUser): """ @@ -66,11 +68,16 @@ class Exam(TimeStampedModel): .. no_pii: """ + EXAM_CHOICES = ( + (exam_type.name, exam_type.description) + for exam_type in EXAM_TYPES + ) + resource_id = models.CharField(max_length=255, db_index=True) course_id = models.CharField(max_length=255, db_index=True) - provider = models.ForeignKey(ProctoringProvider, on_delete=models.CASCADE) + provider = models.ForeignKey(ProctoringProvider, on_delete=models.CASCADE, null=True) # pointer to the id of the piece of course_ware that is the proctored exam. content_id = models.CharField(max_length=255, db_index=True) @@ -79,7 +86,7 @@ class Exam(TimeStampedModel): exam_name = models.TextField() # type of Exam (proctored, practice, etc). - exam_type = models.CharField(max_length=255, db_index=True) + exam_type = models.CharField(max_length=255, choices=EXAM_CHOICES, db_index=True) # Time limit (in minutes) that a student can finish this exam. time_limit_mins = models.PositiveIntegerField() @@ -97,6 +104,7 @@ class Meta: """ Meta class for this Django model """ db_table = 'exams_exam' verbose_name = 'exam' + unique_together = (('course_id', 'content_id', 'exam_type', 'provider'),) class ExamAttempt(TimeStampedModel): diff --git a/edx_exams/settings/test.py b/edx_exams/settings/test.py index 31a23305..427ee8eb 100644 --- a/edx_exams/settings/test.py +++ b/edx_exams/settings/test.py @@ -14,3 +14,10 @@ }, } # END IN-MEMORY TEST DATABASE +JWT_AUTH.update( + { + "JWT_SECRET_KEY": SOCIAL_AUTH_EDX_OAUTH2_SECRET, + "JWT_ISSUER": "https://test-provider/oauth2", + "JWT_AUDIENCE": SOCIAL_AUTH_EDX_OAUTH2_KEY, + } +) diff --git a/requirements/base.txt b/requirements/base.txt index 18e60d43..933b4d46 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -233,7 +233,7 @@ slumber==0.7.1 # via edx-rest-api-client social-auth-app-django==5.0.0 # via edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # edx-auth-backends # social-auth-app-django diff --git a/requirements/dev.txt b/requirements/dev.txt index fe5068f6..8927dd2e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -12,7 +12,7 @@ asgiref==3.5.2 # via # -r requirements/validation.txt # django -astroid==2.11.5 +astroid==2.11.6 # via # -r requirements/validation.txt # pylint @@ -83,6 +83,8 @@ cryptography==37.0.2 # pyjwt # secretstorage # social-auth-core +ddt==1.5.0 + # via -r requirements/validation.txt defusedxml==0.7.1 # via # -r requirements/validation.txt @@ -195,6 +197,12 @@ edx-opaque-keys==2.3.0 # lti-consumer-xblock edx-rest-api-client==5.5.0 # via -r requirements/validation.txt +factory-boy==3.2.1 + # via -r requirements/validation.txt +faker==13.13.0 + # via + # -r requirements/validation.txt + # factory-boy filelock==3.7.1 # via # -r requirements/validation.txt @@ -380,7 +388,7 @@ pyjwt[crypto]==2.4.0 # edx-drf-extensions # edx-rest-api-client # social-auth-core -pylint==2.14.1 +pylint==2.14.2 # via # -r requirements/validation.txt # edx-lint @@ -425,6 +433,7 @@ python-dateutil==2.8.2 # via # -r requirements/validation.txt # edx-drf-extensions + # faker # xblock python-slugify==6.1.2 # via @@ -526,7 +535,7 @@ social-auth-app-django==5.0.0 # via # -r requirements/validation.txt # edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # -r requirements/validation.txt # edx-auth-backends diff --git a/requirements/doc.txt b/requirements/doc.txt index 5f31cbca..7c001bdb 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -14,7 +14,7 @@ asgiref==3.5.2 # via # -r requirements/test.txt # django -astroid==2.11.5 +astroid==2.11.6 # via # -r requirements/test.txt # pylint @@ -23,7 +23,7 @@ attrs==21.4.0 # via # -r requirements/test.txt # pytest -babel==2.10.1 +babel==2.10.2 # via sphinx bleach==5.0.0 # via @@ -83,6 +83,8 @@ cryptography==37.0.2 # pyjwt # secretstorage # social-auth-core +ddt==1.5.0 + # via -r requirements/test.txt defusedxml==0.7.1 # via # -r requirements/test.txt @@ -194,6 +196,12 @@ edx-rest-api-client==5.5.0 # via -r requirements/test.txt edx-sphinx-theme==3.0.0 # via -r requirements/doc.in +factory-boy==3.2.1 + # via -r requirements/test.txt +faker==13.13.0 + # via + # -r requirements/test.txt + # factory-boy filelock==3.7.1 # via # -r requirements/test.txt @@ -365,7 +373,7 @@ pyjwt[crypto]==2.4.0 # edx-drf-extensions # edx-rest-api-client # social-auth-core -pylint==2.14.1 +pylint==2.14.2 # via # -r requirements/test.txt # edx-lint @@ -410,6 +418,7 @@ python-dateutil==2.8.2 # via # -r requirements/test.txt # edx-drf-extensions + # faker # xblock python-slugify==6.1.2 # via @@ -503,7 +512,7 @@ social-auth-app-django==5.0.0 # via # -r requirements/test.txt # edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # -r requirements/test.txt # edx-auth-backends diff --git a/requirements/pip.txt b/requirements/pip.txt index caa4a5b9..d59dc3d8 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -10,5 +10,5 @@ wheel==0.37.1 # The following packages are considered to be unsafe in a requirements file: pip==22.1.2 # via -r requirements/pip.in -setuptools==62.3.3 +setuptools==62.4.0 # via -r requirements/pip.in diff --git a/requirements/production.txt b/requirements/production.txt index b74e9592..5634314f 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -334,7 +334,7 @@ social-auth-app-django==5.0.0 # via # -r requirements/base.txt # edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # -r requirements/base.txt # edx-auth-backends diff --git a/requirements/quality.txt b/requirements/quality.txt index ca55dcae..b7d5942c 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -12,7 +12,7 @@ asgiref==3.5.2 # via # -r requirements/test.txt # django -astroid==2.11.5 +astroid==2.11.6 # via # -r requirements/test.txt # pylint @@ -77,6 +77,8 @@ cryptography==37.0.2 # pyjwt # secretstorage # social-auth-core +ddt==1.5.0 + # via -r requirements/test.txt defusedxml==0.7.1 # via # -r requirements/test.txt @@ -182,6 +184,12 @@ edx-opaque-keys==2.3.0 # lti-consumer-xblock edx-rest-api-client==5.5.0 # via -r requirements/test.txt +factory-boy==3.2.1 + # via -r requirements/test.txt +faker==13.13.0 + # via + # -r requirements/test.txt + # factory-boy filelock==3.7.1 # via # -r requirements/test.txt @@ -348,7 +356,7 @@ pyjwt[crypto]==2.4.0 # edx-drf-extensions # edx-rest-api-client # social-auth-core -pylint==2.14.1 +pylint==2.14.2 # via # -r requirements/test.txt # edx-lint @@ -393,6 +401,7 @@ python-dateutil==2.8.2 # via # -r requirements/test.txt # edx-drf-extensions + # faker # xblock python-slugify==6.1.2 # via @@ -481,7 +490,7 @@ social-auth-app-django==5.0.0 # via # -r requirements/test.txt # edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # -r requirements/test.txt # edx-auth-backends diff --git a/requirements/test.in b/requirements/test.in index 58520862..c0a184ec 100644 --- a/requirements/test.in +++ b/requirements/test.in @@ -4,8 +4,10 @@ code-annotations coverage +ddt django-dynamic-fixture # library to create dynamic model instances for testing purposes edx-lint +factory-boy mock pytest-cov pytest-django diff --git a/requirements/test.txt b/requirements/test.txt index 4f9a7ec2..58a20967 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -12,7 +12,7 @@ asgiref==3.5.2 # via # -r requirements/base.txt # django -astroid==2.11.5 +astroid==2.11.6 # via # pylint # pylint-celery @@ -68,6 +68,8 @@ cryptography==37.0.2 # -r requirements/base.txt # pyjwt # social-auth-core +ddt==1.5.0 + # via -r requirements/test.in defusedxml==0.7.1 # via # -r requirements/base.txt @@ -163,6 +165,10 @@ edx-opaque-keys==2.3.0 # lti-consumer-xblock edx-rest-api-client==5.5.0 # via -r requirements/base.txt +factory-boy==3.2.1 + # via -r requirements/test.in +faker==13.13.0 + # via factory-boy filelock==3.7.1 # via # tox @@ -296,7 +302,7 @@ pyjwt[crypto]==2.4.0 # edx-drf-extensions # edx-rest-api-client # social-auth-core -pylint==2.14.1 +pylint==2.14.2 # via # edx-lint # pylint-celery @@ -334,6 +340,7 @@ python-dateutil==2.8.2 # via # -r requirements/base.txt # edx-drf-extensions + # faker # xblock python-slugify==6.1.2 # via code-annotations @@ -406,7 +413,7 @@ social-auth-app-django==5.0.0 # via # -r requirements/base.txt # edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # -r requirements/base.txt # edx-auth-backends diff --git a/requirements/validation.txt b/requirements/validation.txt index 0fdc45ac..a6d0b741 100644 --- a/requirements/validation.txt +++ b/requirements/validation.txt @@ -14,7 +14,7 @@ asgiref==3.5.2 # -r requirements/quality.txt # -r requirements/test.txt # django -astroid==2.11.5 +astroid==2.11.6 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -94,6 +94,10 @@ cryptography==37.0.2 # pyjwt # secretstorage # social-auth-core +ddt==1.5.0 + # via + # -r requirements/quality.txt + # -r requirements/test.txt defusedxml==0.7.1 # via # -r requirements/quality.txt @@ -233,6 +237,15 @@ edx-rest-api-client==5.5.0 # via # -r requirements/quality.txt # -r requirements/test.txt +factory-boy==3.2.1 + # via + # -r requirements/quality.txt + # -r requirements/test.txt +faker==13.13.0 + # via + # -r requirements/quality.txt + # -r requirements/test.txt + # factory-boy filelock==3.7.1 # via # -r requirements/quality.txt @@ -441,7 +454,7 @@ pyjwt[crypto]==2.4.0 # edx-drf-extensions # edx-rest-api-client # social-auth-core -pylint==2.14.1 +pylint==2.14.2 # via # -r requirements/quality.txt # -r requirements/test.txt @@ -499,6 +512,7 @@ python-dateutil==2.8.2 # -r requirements/quality.txt # -r requirements/test.txt # edx-drf-extensions + # faker # xblock python-slugify==6.1.2 # via @@ -612,7 +626,7 @@ social-auth-app-django==5.0.0 # -r requirements/quality.txt # -r requirements/test.txt # edx-auth-backends -social-auth-core==4.2.0 +social-auth-core==4.3.0 # via # -r requirements/quality.txt # -r requirements/test.txt