def push_ecommerce_entitlement(self,
                                   partner,
                                   course,
                                   entitlement,
                                   partial=False):
        """ Creates or updates the stockrecord information on the ecommerce side. """
        api_client = OAuthAPIClient(partner.lms_url, partner.oidc_key,
                                    partner.oidc_secret)

        if partial:
            method = 'PUT'
            url = '{0}stockrecords/{1}/'.format(partner.ecommerce_api_url,
                                                entitlement.sku)
            data = {
                'price_excl_tax': entitlement.price,
            }
        else:
            method = 'POST'
            url = '{0}products/'.format(partner.ecommerce_api_url)
            data = {
                'product_class': 'Course Entitlement',
                'title': course.title,
                'price': entitlement.price,
                'certificate_type': entitlement.mode.slug,
                'uuid': str(course.uuid),
            }

        response = api_client.request(method, url, data=data)
        if not response.ok:
            raise EcommerceAPIClientException(response.text)
        return response
Exemple #2
0
def _request_financial_assistance(method, url, params=None, data=None):
    """
    An internal function containing common functionality among financial assistance utility function to call
    edx-financial-assistance backend with appropriate method, url, params and data.
    """
    financial_assistance_configuration = FinancialAssistanceConfiguration.current()
    if financial_assistance_configuration.enabled:
        oauth_application = Application.objects.get(user=financial_assistance_configuration.get_service_user())
        client = OAuthAPIClient(
            settings.LMS_ROOT_URL,
            oauth_application.client_id,
            oauth_application.client_secret
        )
        return client.request(
            method, f"{financial_assistance_configuration.api_base_url}{url}", params=params, data=data
        )
    else:
        return False, 'Financial Assistance configuration is not enabled'
    def update_val(self, image_keys):
        """
        Update a course video in edxval database for auto generated images.
        """
        if len(image_keys) > 0:

            for course_id in self.video_object.course_url:
                data = {
                    'course_id': course_id,
                    'edx_video_id': self.video_object.val_id,
                    'generated_images': image_keys
                }

                client = OAuthAPIClient(self.settings['oauth2_provider_url'],
                                        self.settings['oauth2_client_id'],
                                        self.settings['oauth2_client_secret'])

                response = client.request(
                    'POST', self.settings['val_video_images_url'], json=data)

                if not response.ok:
                    logger.error(': {id} {message}'.format(
                        id=self.video_object.val_id, message=response.content))
class VALAPICall(object):
    def __init__(self, video_proto, val_status, **kwargs):
        """VAL Data"""
        self.val_status = val_status
        self.platform_course_url = kwargs.get('platform_course_url', [])
        """VEDA Data"""
        self.video_proto = video_proto
        self.video_object = kwargs.get('video_object', None)
        self.encode_profile = kwargs.get('encode_profile', None)
        """if sending urls"""
        self.endpoint_url = kwargs.get('endpoint_url', None)
        self.encode_data = []
        self.val_profile = None
        """Generated"""
        self.val_data = None
        self.headers = None
        """Credentials"""
        self.auth_dict = kwargs.get('CONFIG_DATA', self._AUTH())
        self.oauth2_provider_url = self.auth_dict['oauth2_provider_url']
        self.oauth2_client_id = self.auth_dict['oauth2_client_id']
        self.oauth2_client_secret = self.auth_dict['oauth2_client_secret']
        self.oauth2_client = OAuthAPIClient(
            self.auth_dict['oauth2_provider_url'],
            self.auth_dict['oauth2_client_id'],
            self.auth_dict['oauth2_client_secret'])

    def _AUTH(self):
        return get_config()

    def call(self):
        if not self.auth_dict:
            return None
        """
        Errors covered in other methods
        """
        if self.video_object:
            self.send_object_data()
            return
        if self.video_proto is not None:
            self.send_val_data()

    def send_object_data(self):
        """
        Rather than rewrite the protocol to fit the veda models,
        we'll shoehorn the model into the VideoProto model
        """
        self.video_proto = VideoProto()

        self.video_proto.s3_filename = self.video_object.studio_id
        self.video_proto.veda_id = self.video_object.edx_id
        self.video_proto.client_title = self.video_object.client_title
        if self.video_proto.client_title is None:
            self.video_proto.client_title = ''
        self.video_proto.duration = self.video_object.video_orig_duration

        self.send_val_data()

    def send_val_data(self):
        """
        VAL is very tetchy -- it needs a great deal of specific info or it will fail
        """
        '''
        sending_data = {
            encoded_videos = [{
                url="https://testurl.mp4",
                file_size=8499040,
                bitrate=131,
                profile="override",
                }, {...},],
            client_video_id = "This is a VEDA-VAL Test",
            courses = [ "TEST", "..." ],
            duration = 517.82,
            edx_video_id = "TESTID",
            status = "transcode_active"
            }
        ## "POST" for new objects to 'video' root url
        ## "PUT" for extant objects to video/id --
            cannot send duplicate course records
        '''

        if self.video_proto.s3_filename is None or \
                len(self.video_proto.s3_filename) == 0:
            self.video_proto.val_id = self.video_proto.veda_id

        else:
            self.video_proto.val_id = self.video_proto.s3_filename

        if self.val_status != 'invalid_token':
            self.video_object = Video.objects.filter(
                edx_id=self.video_proto.veda_id).latest()
        """
        Data Cleaning
        """
        if self.video_proto.platform_course_url is None:
            self.video_proto.platform_course_url = []

        if not isinstance(self.video_proto.platform_course_url, list):
            self.video_proto.platform_course_url = [
                self.video_proto.platform_course_url
            ]

        try:
            self.video_object.video_orig_duration
        except NameError:
            self.video_object.video_orig_duration = 0
            self.video_object.duration = 0.0

        except AttributeError:
            pass

        if not isinstance(self.video_proto.duration,
                          float) and self.val_status != 'invalid_token':
            self.video_proto.duration = Output._seconds_from_string(
                duration=self.video_object.video_orig_duration)
        """
        Sort out courses
        """
        val_courses = []
        if self.val_status != 'invalid_token':
            for f in self.video_object.inst_class.local_storedir.split(','):
                if f.strip() not in val_courses and len(f.strip()) > 0:
                    val_courses.append({f.strip(): None})

        for g in self.video_proto.platform_course_url:
            if g.strip() not in val_courses:
                val_courses.append({g.strip(): None})

        self.val_data = {
            'client_video_id': self.video_proto.client_title,
            'duration': self.video_proto.duration,
            'edx_video_id': self.video_proto.val_id,
            'courses': val_courses
        }

        r1 = self.oauth2_client.request(
            'GET', '/'.join(
                (self.auth_dict['val_api_url'], self.video_proto.val_id)))

        if r1.status_code != 200 and r1.status_code != 404:
            LOGGER.error('[API] : VAL Communication error %d', r1.status_code)
            return

        if r1.status_code == 404:
            self.send_404()

        elif r1.status_code == 200:
            val_api_return = ast.literal_eval(r1.text.replace('null', 'None'))
            self.send_200(val_api_return)
        """
        Update Status
        """
        LOGGER.info('[INGEST] send_val_data : video ID : %s',
                    self.video_proto.veda_id)
        url_query = URL.objects.filter(videoID=Video.objects.filter(
            edx_id=self.video_proto.veda_id))
        for u in url_query:
            URL.objects.filter(pk=u.pk).update(val_input=True)

    def profile_determiner(self, val_api_return):
        """
        Determine VAL profile data, from return/encode submix

        """
        # Defend against old/deprecated encodes
        if self.encode_profile:
            try:
                self.auth_dict['val_profile_dict'][self.encode_profile]
            except KeyError:
                return
        if self.endpoint_url:
            for p in self.auth_dict['val_profile_dict'][self.encode_profile]:

                self.encode_data.append(
                    dict(url=self.endpoint_url,
                         file_size=self.video_proto.filesize,
                         bitrate=int(self.video_proto.bitrate.split(' ')[0]),
                         profile=p))

        test_list = []
        if self.video_proto.veda_id:
            url_query = URL.objects.filter(videoID=Video.objects.filter(
                edx_id=self.video_proto.veda_id).latest())
            for u in url_query:
                final = URL.objects.filter(encode_profile=u.encode_profile,
                                           videoID=u.videoID).latest()

                if final.encode_profile.product_spec == 'review':
                    pass
                else:
                    try:
                        self.auth_dict['val_profile_dict'][
                            final.encode_profile.product_spec]
                    except KeyError:
                        continue
                    for p in self.auth_dict['val_profile_dict'][
                            final.encode_profile.product_spec]:
                        test_list.append(
                            dict(url=str(final.encode_url),
                                 file_size=final.encode_size,
                                 bitrate=int(
                                     final.encode_bitdepth.split(' ')[0]),
                                 profile=str(p)))

        for t in test_list:
            if t['profile'] not in [g['profile'] for g in self.encode_data]:
                self.encode_data.append(t)

        if len(val_api_return) == 0:
            return
        """
        All URL Records Deleted (for some reason)
        """
        if len(self.encode_data) == 0:
            return

        for i in val_api_return['encoded_videos']:
            if i['profile'] not in [g['profile'] for g in self.encode_data]:
                self.encode_data.append(i)

        return

    @staticmethod
    def should_update_status(encode_list, val_status):
        """
        Check if we need to update video status in val

        Arguments:
            encode_list (list): list of video encodes
            val_status (unicode): val status
        """
        if len(encode_list) == 0 and val_status in FILE_COMPLETE_STATUSES:
            return False

        return True

    def send_404(self):
        """
        Generate new VAL ID
        """
        self.profile_determiner(val_api_return=[])

        self.val_data['status'] = self.val_status

        if self.should_update_status(self.encode_data,
                                     self.val_status) is False:
            return None

        sending_data = dict(encoded_videos=self.encode_data, **self.val_data)

        r2 = self.oauth2_client.request('POST',
                                        '/'.join(
                                            (self.auth_dict['val_api_url'],
                                             '')),
                                        json=sending_data)
        if r2.status_code > 299:
            LOGGER.error('[API] : VAL POST {code}'.format(code=r2.status_code))

    def send_200(self, val_api_return):
        """
        VAL ID is previously extant
        just update
        ---
        VAL will not allow duped studio urls to be sent,
        so we must scrub the data
        """
        for retrieved_course in val_api_return['courses']:
            for course in list(self.val_data['courses']):
                if list(retrieved_course.keys()).sort() == list(
                        course.keys()).sort():
                    self.val_data['courses'].remove(course)

        self.profile_determiner(val_api_return=val_api_return)
        self.val_data['status'] = self.val_status
        """
        Double check for profiles in case of overwrite
        """
        sending_data = dict(encoded_videos=self.encode_data, **self.val_data)
        """
        Make Request, finally
        """
        if self.should_update_status(self.encode_data,
                                     self.val_status) is False:
            return None

        r4 = self.oauth2_client.request('PUT',
                                        '/'.join(
                                            (self.auth_dict['val_api_url'],
                                             self.video_proto.val_id)),
                                        json=sending_data)
        LOGGER.info('[API] {id} : {status} sent to VAL {code}'.format(
            id=self.video_proto.val_id,
            status=self.val_status,
            code=r4.status_code))
        if r4.status_code > 299:
            LOGGER.error(
                '[API] : VAL PUT : {status}'.format(status=r4.status_code))

    def update_val_transcript(self, video_id, lang_code, name,
                              transcript_format, provider):
        """
        Update status for a completed transcript.
        """

        post_data = {
            'video_id': video_id,
            'name': name,
            'provider': provider,
            'language_code': lang_code,
            'file_format': transcript_format,
        }

        response = self.oauth2_client.request(
            'POST',
            self.auth_dict['val_transcript_create_url'],
            json=post_data)
        if not response.ok:
            LOGGER.error(
                '[API] : VAL update_val_transcript failed -- video_id=%s -- provider=% -- status=%s -- content=%s',
                video_id,
                provider,
                response.status_code,
                response.content,
            )

    def update_video_status(self, video_id, status):
        """
        Update video transcript status.
        """
        val_data = {'edx_video_id': video_id, 'status': status}

        response = self.oauth2_client.request(
            'PATCH',
            self.auth_dict['val_video_transcript_status_url'],
            json=val_data)
        if not response.ok:
            LOGGER.error(
                '[API] : VAL Update_video_status failed -- video_id=%s -- status=%s -- text=%s',
                video_id, response.status_code, response.text)
class BaseRestProctoringProvider(ProctoringBackendProvider):
    """
    Base class for official REST API proctoring service.
    Subclasses must override base_url and may override the other url
    properties
    """
    base_url = None
    token_expiration_time = 60
    needs_oauth = True
    has_dashboard = True
    supports_onboarding = True
    passing_statuses = (SoftwareSecureReviewStatus.clean, )

    @property
    def exam_attempt_url(self):
        "Returns exam attempt url"
        return self.base_url + u'/api/v1/exam/{exam_id}/attempt/{attempt_id}/'

    @property
    def create_exam_attempt_url(self):
        "Returns the create exam url"
        return self.base_url + u'/api/v1/exam/{exam_id}/attempt/'

    @property
    def create_exam_url(self):
        "Returns create exam url"
        return self.base_url + u'/api/v1/exam/'

    @property
    def exam_url(self):
        "Returns exam url"
        return self.base_url + u'/api/v1/exam/{exam_id}/'

    @property
    def config_url(self):
        "Returns proctor config url"
        return self.base_url + u'/api/v1/config/'

    @property
    def instructor_url(self):
        "Returns the instructor dashboard url"
        return self.base_url + u'/api/v1/instructor/{client_id}/?jwt={jwt}'

    @property
    def user_info_url(self):
        "Returns the user info url"
        return self.base_url + u'/api/v1/user/{user_id}/'

    @property
    def proctoring_instructions(self):
        "Returns the (optional) proctoring instructions"
        return []

    def __init__(self, client_id=None, client_secret=None, **kwargs):
        """
        Initialize REST backend.
        client_id: provided by backend service
        client_secret: provided by backend service
        """
        ProctoringBackendProvider.__init__(self)
        self.client_id = client_id
        self.client_secret = client_secret
        self.default_rules = None
        for key, value in kwargs.items():
            setattr(self, key, value)
        self.session = OAuthAPIClient(self.base_url, self.client_id,
                                      self.client_secret)

    def get_javascript(self):
        """
        Returns the url of the javascript bundle into which the provider's JS will be loaded
        """
        # use the defined npm_module name, or else the python package name
        package = getattr(self, 'npm_module',
                          self.__class__.__module__.split('.')[0])
        js_url = ''
        try:
            bundle_chunks = get_files(package, config="WORKERS")
            # still necessary to check, since webpack_loader can be
            # configured to ignore all matching packages
            if bundle_chunks:
                js_url = bundle_chunks[0]["url"]

        except WebpackBundleLookupError:
            warnings.warn(
                u'Could not find webpack bundle for proctoring backend {package}.'
                u' Check whether webpack is configured to build such a bundle'.
                format(package=package))
        except BaseWebpackLoaderException:
            warnings.warn(
                u'Could not find webpack bundle for proctoring backend {package}.'
                .format(package=package))
        except IOError as err:
            warnings.warn(
                u'Webpack stats file corresponding to WebWorkers not found: {}'
                .format(str(err)))

        # if the Javascript URL is not an absolute URL (i.e. doesn't have a scheme), prepend
        # the LMS Root URL to it, if it is defined, to make it an absolute URL
        if not urlparse(js_url).scheme:
            if hasattr(settings, 'LMS_ROOT_URL'):
                js_url = settings.LMS_ROOT_URL + js_url

        return js_url

    def get_software_download_url(self):
        """
        Returns the URL that the user needs to go to in order to download
        the corresponding desktop software
        """
        return self.get_proctoring_config().get('download_url', None)

    def get_proctoring_config(self):
        """
        Returns the metadata and configuration options for the proctoring service
        """
        url = self.config_url
        log.debug(u'Requesting config from %r', url)
        response = self.session.get(
            url, headers=self._get_language_headers()).json()
        return response

    def get_exam(self, exam):
        """
        Returns the exam metadata stored by the proctoring service
        """
        url = self.exam_url.format(exam_id=exam['id'])
        log.debug(u'Requesting exam from %r', url)
        response = self.session.get(url).json()
        return response

    def get_attempt(self, attempt):
        """
        Returns the attempt object from the backend
        """
        response = self._make_attempt_request(
            attempt['proctored_exam']['external_id'],
            attempt['external_id'],
            method='GET')
        # If the class has instructions defined, use them.
        # Otherwise, the instructions should be returned by this
        # API request. Subclasses should wrap each instruction with gettext
        response[
            'instructions'] = self.proctoring_instructions or response.get(
                'instructions', [])
        return response

    def register_exam_attempt(self, exam, context):
        """
        Called when the exam attempt has been created but not started
        """
        url = self.create_exam_attempt_url.format(exam_id=exam['external_id'])
        payload = context
        payload['status'] = 'created'
        # attempt code isn't needed in this API
        payload.pop('attempt_code', False)
        log.debug(u'Creating exam attempt for %r at %r', exam['external_id'],
                  url)
        response = self.session.post(url, json=payload)
        if response.status_code != 200:
            raise BackendProviderCannotRegisterAttempt(response.content,
                                                       response.status_code)
        status_code = response.status_code
        response = response.json()
        log.debug(response)
        onboarding_status = response.get('status', None)
        if onboarding_status in ProctoredExamStudentAttemptStatus.onboarding_errors:
            raise BackendProviderOnboardingException(onboarding_status)
        attempt_id = response.get('id', None)
        if not attempt_id:
            raise BackendProviderSentNoAttemptID(response, status_code)
        return attempt_id

    def start_exam_attempt(self, exam, attempt):
        """
        Method that is responsible for communicating with the backend provider
        to establish a new proctored exam
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            status=ProctoredExamStudentAttemptStatus.started,
            method='PATCH')
        return response.get('status')

    def stop_exam_attempt(self, exam, attempt):
        """
        Method that is responsible for communicating with the backend provider
        to finish a proctored exam
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            status=ProctoredExamStudentAttemptStatus.submitted,
            method='PATCH')
        return response.get('status')

    def remove_exam_attempt(self, exam, attempt):
        """
        Removes the exam attempt on the backend provider's server
        """
        response = self._make_attempt_request(exam, attempt, method='DELETE')
        return response.get('status', None) == 'deleted'

    def mark_erroneous_exam_attempt(self, exam, attempt):
        """
        Method that is responsible for communicating with the backend provider
        to mark an unfinished exam to be in error
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            status=ProctoredExamStudentAttemptStatus.error,
            method='PATCH')
        return response.get('status')

    def on_review_callback(self, attempt, payload):
        """
        Called when the reviewing 3rd party service posts back the results
        """
        # REST backends should convert the payload into the expected data structure
        return payload

    def on_exam_saved(self, exam):
        """
        Called after an exam is saved.
        """
        if self.default_rules and not exam.get('rules', None):
            # allows the platform to define a default configuration
            exam['rules'] = self.default_rules
        external_id = exam.get('external_id', None)
        if external_id:
            url = self.exam_url.format(exam_id=external_id)
        else:
            url = self.create_exam_url
        log.info(u'Saving exam to %r', url)
        response = None
        try:
            response = self.session.post(url, json=exam)
            data = response.json()
        except Exception as exc:  # pylint: disable=broad-except
            if response:
                # pylint: disable=no-member
                content = exc.response.content if hasattr(
                    exc, 'response') else response.content
            else:
                content = None
            log.exception(u'failed to save exam. %r', content)
            data = {}
        return data.get('id')

    def get_instructor_url(self,
                           course_id,
                           user,
                           exam_id=None,
                           attempt_id=None,
                           show_configuration_dashboard=False):
        """
        Return a URL to the instructor dashboard
        course_id: str
        user: dict of {id, full_name, email} for the instructor or reviewer
        exam_id: str optional exam external id
        attempt_id: str optional exam attempt external id
        """
        exp = time.time() + self.token_expiration_time
        token = {
            'course_id': course_id,
            'user': user,
            'iss': self.client_id,
            'jti': uuid.uuid4().hex,
            'exp': exp
        }
        if exam_id:
            token['exam_id'] = exam_id
            if show_configuration_dashboard:
                token['config'] = True
            if attempt_id:
                token['attempt_id'] = attempt_id
        encoded = jwt.encode(token, self.client_secret).decode('utf-8')
        url = self.instructor_url.format(client_id=self.client_id, jwt=encoded)

        log.debug(u'Created instructor url for %r %r %r', course_id, exam_id,
                  attempt_id)
        return url

    def retire_user(self, user_id):
        url = self.user_info_url.format(user_id=user_id)
        try:
            response = self.session.delete(url)
            data = response.json()
            assert data in (True, False)
        except Exception as exc:  # pylint: disable=broad-except
            # pylint: disable=no-member
            content = exc.response.content if hasattr(
                exc, 'response') else response.content
            raise BackendProviderCannotRetireUser(content)
        return data

    def _get_language_headers(self):
        """
        Returns a dictionary of the Accept-Language headers
        """
        # This import is here because developers writing backends which subclass this class
        # may want to import this module and use the other methods, without having to run in the context
        # of django settings, etc.
        from django.utils.translation import get_language  # pylint: disable=import-outside-toplevel

        current_lang = get_language()
        default_lang = settings.LANGUAGE_CODE
        lang_header = default_lang
        if current_lang and current_lang != default_lang:
            lang_header = '{};{}'.format(current_lang, default_lang)
        return {'Accept-Language': lang_header}

    def _make_attempt_request(self,
                              exam,
                              attempt,
                              method='POST',
                              status=None,
                              **payload):
        """
        Calls backend attempt API
        """
        if not attempt:
            return {}
        if status:
            payload['status'] = status
        else:
            payload = None
        url = self.exam_attempt_url.format(exam_id=exam, attempt_id=attempt)
        headers = {}
        if method == 'GET':
            headers.update(self._get_language_headers())
        log.debug(u'Making %r attempt request at %r', method, url)
        response = self.session.request(method,
                                        url,
                                        json=payload,
                                        headers=headers)
        try:
            data = response.json()
        except ValueError:
            log.exception(u"Decoding attempt %r -> %r", attempt,
                          response.content)
            data = {}
        return data
Exemple #6
0
    def send_val_data(self):
        """
        VAL is very tetchy -- it needs a great deal of specific info or it will fail
        """
        '''
        sending_data = {
            encoded_videos = [{
                url="https://testurl.mp4",
                file_size=8499040,
                bitrate=131,
                profile="override",
                }, {...},],
            client_video_id = "This is a VEDA-VAL Test",
            courses = [ "TEST", "..." ],
            duration = 517.82,
            edx_video_id = "TESTID",
            status = "transcode_active"
            }
        ## "POST" for new objects to 'video' root url
        ## "PUT" for extant objects to video/id --
            cannot send duplicate course records
        '''

        # in case non-studio side upload
        if self.VideoObject.val_id is None or len(
                self.VideoObject.val_id) == 0:
            self.VideoObject.val_id = self.VideoObject.veda_id

        val_data = {
            'client_video_id': self.VideoObject.val_id,
            'duration': self.VideoObject.mezz_duration,
            'edx_video_id': self.VideoObject.val_id,
        }

        if not isinstance(self.VideoObject.course_url, list):
            self.VideoObject.course_url = [self.VideoObject.course_url]

        client = OAuthAPIClient(settings['oauth2_provider_url'],
                                settings['oauth2_client_id'],
                                settings['oauth2_client_secret'])
        r1 = client.request(
            'GET', '/'.join(
                (settings['val_api_url'], self.VideoObject.val_id, '')))

        if r1.status_code != 200 and r1.status_code != 404:
            # Total API Failure
            logger.error('VAL Communication error %d', r1.status_code)
            return None

        if r1.status_code == 404:
            # Generate new VAL ID (shouldn't happen, but whatever)
            val_data['encoded_videos'] = []
            val_data['courses'] = self.VideoObject.course_url
            val_data['status'] = self.val_video_status

            # Final Connection
            r2 = client.request('POST', settings['val_api_url'], json=val_data)
            if r2.status_code > 299:
                logger.error('VAL POST error %d', r2.status_code)
                return None

        elif r1.status_code == 200:
            # ID is previously extant
            val_api_return = ast.literal_eval(r1.text)
            # extract course ids, courses will be a list of dicts, [{'course_id': 'image_name'}]
            course_ids = reduce(operator.concat,
                                (list(d.keys())
                                 for d in val_api_return['courses']))

            # VAL will not allow duped studio urls to be sent, so
            # we must scrub the data

            for course_id in self.VideoObject.course_url:
                if course_id in course_ids:
                    self.VideoObject.course_url.remove(course_id)

            val_data['courses'] = self.VideoObject.course_url

            # Double check for profiles in case of overwrite
            val_data['encoded_videos'] = []
            # add back in the encodes
            for e in val_api_return['encoded_videos']:
                val_data['encoded_videos'].append(e)

            # Determine Status
            val_data['status'] = self.val_video_status

            # Make Request, finally
            r2 = client.request('PUT',
                                '/'.join((settings['val_api_url'],
                                          self.VideoObject.val_id)),
                                json=val_data)

            if r2.status_code > 299:
                logger.error('VAL PUT error %d', r2.status_code)
                return None
Exemple #7
0
class BaseRestProctoringProvider(ProctoringBackendProvider):
    """
    Base class for official REST API proctoring service.
    Subclasses must override base_url and may override the other url
    properties
    """
    base_url = None
    token_expiration_time = 60
    needs_oauth = True
    has_dashboard = True
    supports_onboarding = True
    passing_statuses = (SoftwareSecureReviewStatus.clean,)

    @property
    def exam_attempt_url(self):
        "Returns exam attempt url"
        return self.base_url + u'/api/v1/exam/{exam_id}/attempt/{attempt_id}/'

    @property
    def create_exam_attempt_url(self):
        "Returns the create exam url"
        return self.base_url + u'/api/v1/exam/{exam_id}/attempt/'

    @property
    def create_exam_url(self):
        "Returns create exam url"
        return self.base_url + u'/api/v1/exam/'

    @property
    def exam_url(self):
        "Returns exam url"
        return self.base_url + u'/api/v1/exam/{exam_id}/'

    @property
    def config_url(self):
        "Returns proctor config url"
        return self.base_url + u'/api/v1/config/'

    @property
    def instructor_url(self):
        "Returns the instructor dashboard url"
        return self.base_url + u'/api/v1/instructor/{client_id}/?jwt={jwt}'

    @property
    def user_info_url(self):
        "Returns the user info url"
        return self.base_url + u'/api/v1/user/{user_id}/'

    @property
    def proctoring_instructions(self):
        "Returns the (optional) proctoring instructions"
        return []

    def __init__(self, client_id=None, client_secret=None, **kwargs):
        """
        Initialize REST backend.
        client_id: provided by backend service
        client_secret: provided by backend service
        """
        ProctoringBackendProvider.__init__(self)
        self.client_id = client_id
        self.client_secret = client_secret
        self.default_rules = None
        for key, value in kwargs.items():
            setattr(self, key, value)
        self.session = OAuthAPIClient(self.base_url, self.client_id, self.client_secret)

    def get_javascript(self):
        """
        Returns the url of the javascript bundle into which the provider's JS will be loaded
        """
        # use the defined npm_module name, or else the python package name
        package = getattr(self, 'npm_module', self.__class__.__module__.split('.')[0])
        js_url = ''
        try:
            bundle_chunks = get_files(package, config="WORKERS")
            # still necessary to check, since webpack_loader can be
            # configured to ignore all matching packages
            if bundle_chunks:
                js_url = bundle_chunks[0]["url"]

        except WebpackBundleLookupError:
            warnings.warn(
                u'Could not find webpack bundle for proctoring backend {package}.'
                u' Check whether webpack is configured to build such a bundle'.format(
                    package=package
                )
            )
        except BaseWebpackLoaderException:
            warnings.warn(
                u'Could not find webpack bundle for proctoring backend {package}.'.format(
                    package=package
                )
            )
        except IOError as err:
            warnings.warn(
                u'Webpack stats file corresponding to WebWorkers not found: {}'
                .format(str(err))
            )

        # if the Javascript URL is not an absolute URL (i.e. doesn't have a scheme), prepend
        # the LMS Root URL to it, if it is defined, to make it an absolute URL
        if not urlparse(js_url).scheme:
            if hasattr(settings, 'LMS_ROOT_URL'):
                js_url = settings.LMS_ROOT_URL + js_url

        return js_url

    def get_software_download_url(self):
        """
        Returns the URL that the user needs to go to in order to download
        the corresponding desktop software
        """
        return self.get_proctoring_config().get('download_url', None)

    def get_proctoring_config(self):
        """
        Returns the metadata and configuration options for the proctoring service
        """
        url = self.config_url
        log.debug('Requesting config from %r', url)
        response = self.session.get(url, headers=self._get_language_headers()).json()
        return response

    def get_exam(self, exam):
        """
        Returns the exam metadata stored by the proctoring service
        """
        url = self.exam_url.format(exam_id=exam['id'])
        log.debug('Requesting exam from %r', url)
        response = self.session.get(url).json()
        return response

    def get_attempt(self, attempt):
        """
        Returns the attempt object from the backend
        """
        response = self._make_attempt_request(
            attempt['proctored_exam']['external_id'],
            attempt['external_id'],
            method='GET')
        # If the class has instructions defined, use them.
        # Otherwise, the instructions should be returned by this
        # API request. Subclasses should wrap each instruction with gettext
        response['instructions'] = self.proctoring_instructions or response.get('instructions', [])
        return response

    def register_exam_attempt(self, exam, context):
        """
        Called when the exam attempt has been created but not started
        """
        url = self.create_exam_attempt_url.format(exam_id=exam['external_id'])
        payload = context
        payload['status'] = 'created'
        # attempt code isn't needed in this API
        payload.pop('attempt_code', False)
        log.debug('Creating exam attempt for %r at %r', exam['external_id'], url)
        response = self.session.post(url, json=payload)
        if response.status_code != 200:
            raise BackendProviderCannotRegisterAttempt(response.content)
        response = response.json()
        log.debug(response)
        onboarding_status = response.get('status', None)
        if onboarding_status in ProctoredExamStudentAttemptStatus.onboarding_errors:
            raise BackendProviderOnboardingException(onboarding_status)
        return response['id']

    def start_exam_attempt(self, exam, attempt):
        """
        Method that is responsible for communicating with the backend provider
        to establish a new proctored exam
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            status=ProctoredExamStudentAttemptStatus.started,
            method='PATCH')
        return response.get('status')

    def stop_exam_attempt(self, exam, attempt):
        """
        Method that is responsible for communicating with the backend provider
        to finish a proctored exam
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            status=ProctoredExamStudentAttemptStatus.submitted,
            method='PATCH')
        return response.get('status')

    def remove_exam_attempt(self, exam, attempt):
        """
        Removes the exam attempt on the backend provider's server
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            method='DELETE')
        return response.get('status', None) == 'deleted'

    def mark_erroneous_exam_attempt(self, exam, attempt):
        """
        Method that is responsible for communicating with the backend provider
        to mark an unfinished exam to be in error
        """
        response = self._make_attempt_request(
            exam,
            attempt,
            status=ProctoredExamStudentAttemptStatus.error,
            method='PATCH')
        return response.get('status')

    def on_review_callback(self, attempt, payload):
        """
        Called when the reviewing 3rd party service posts back the results
        """
        # REST backends should convert the payload into the expected data structure
        return payload

    def on_exam_saved(self, exam):
        """
        Called after an exam is saved.
        """
        if self.default_rules and not exam.get('rules', None):
            # allows the platform to define a default configuration
            exam['rules'] = self.default_rules
        external_id = exam.get('external_id', None)
        if external_id:
            url = self.exam_url.format(exam_id=external_id)
        else:
            url = self.create_exam_url
        log.info('Saving exam to %r', url)
        response = None
        try:
            response = self.session.post(url, json=exam)
            data = response.json()
        except Exception as exc:  # pylint: disable=broad-except
            # pylint: disable=no-member
            content = exc.response.content if hasattr(exc, 'response') else response.content
            log.exception('failed to save exam. %r', content)
            data = {}
        return data.get('id')

    def get_instructor_url(self, course_id, user, exam_id=None, attempt_id=None, show_configuration_dashboard=False):
        """
        Return a URL to the instructor dashboard
        course_id: str
        user: dict of {id, full_name, email} for the instructor or reviewer
        exam_id: str optional exam external id
        attempt_id: str optional exam attempt external id
        """
        exp = time.time() + self.token_expiration_time
        token = {
            'course_id': course_id,
            'user': user,
            'iss': self.client_id,
            'jti': uuid.uuid4().hex,
            'exp': exp
        }
        if exam_id:
            token['exam_id'] = exam_id
            if show_configuration_dashboard:
                token['config'] = True
            if attempt_id:
                token['attempt_id'] = attempt_id
        encoded = jwt.encode(token, self.client_secret)
        url = self.instructor_url.format(client_id=self.client_id, jwt=encoded)

        log.debug('Created instructor url for %r %r %r', course_id, exam_id, attempt_id)
        return url

    def retire_user(self, user_id):
        url = self.user_info_url.format(user_id=user_id)
        try:
            response = self.session.delete(url)
            data = response.json()
            assert data in (True, False)
        except Exception as exc:  # pylint: disable=broad-except
            # pylint: disable=no-member
            content = exc.response.content if hasattr(exc, 'response') else response.content
            raise BackendProviderCannotRetireUser(content)
        return data

    def _get_language_headers(self):
        """
        Returns a dictionary of the Accept-Language headers
        """
        # This import is here because developers writing backends which subclass this class
        # may want to import this module and use the other methods, without having to run in the context
        # of django settings, etc.
        from django.utils.translation import get_language

        current_lang = get_language()
        default_lang = settings.LANGUAGE_CODE
        lang_header = default_lang
        if current_lang and current_lang != default_lang:
            lang_header = '{};{}'.format(current_lang, default_lang)
        return {'Accept-Language': lang_header}

    def _make_attempt_request(self, exam, attempt, method='POST', status=None, **payload):
        """
        Calls backend attempt API
        """
        if not attempt:
            return {}
        if status:
            payload['status'] = status
        else:
            payload = None
        url = self.exam_attempt_url.format(exam_id=exam, attempt_id=attempt)
        headers = {}
        if method == 'GET':
            headers.update(self._get_language_headers())
        log.debug('Making %r attempt request at %r', method, url)
        response = self.session.request(method, url, json=payload, headers=headers)
        try:
            data = response.json()
        except ValueError:
            log.exception("Decoding attempt %r -> %r", attempt, response.content)
            data = {}
        return data