Example #1
0
async def setup_course_hook(
    authenticator: Authenticator, handler: RequestHandler, authentication: Dict[str, str]
) -> Dict[str, str]:
    """
    Calls the microservice to setup up a new course in case it does not exist.
    The data needed is received from auth_state within authentication object. This
    function assumes that the required k/v's in the auth_state dictionary are available,
    since the Authenticator(s) validates the data beforehand.

    This function requires `Authenticator.enable_auth_state = True` and is intended
    to be used as a post_auth_hook.

    Args:
        authenticator: the JupyterHub Authenticator object
        handler: the JupyterHub handler object
        authentication: the authentication object returned by the
          authenticator class

    Returns:
        authentication (Required): updated authentication object
    """
    lti_utils = LTIUtils()
    jupyterhub_api = JupyterHubAPI()

    # normalize the name and course_id strings in authentication dictionary
    course_id = lti_utils.normalize_string(authentication['auth_state']['course_id'])
    nb_service = NbGraderServiceHelper(course_id)
    username = lti_utils.normalize_string(authentication['name'])
    lms_user_id = authentication['auth_state']['lms_user_id']
    user_role = authentication['auth_state']['user_role']
    # register the user (it doesn't matter if it is a student or instructor) with her/his lms_user_id in nbgrader
    nb_service.add_user_to_nbgrader_gradebook(username, lms_user_id)
    # TODO: verify the logic to simplify groups creation and membership
    if user_is_a_student(user_role):
        # assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook
        await jupyterhub_api.add_student_to_jupyterhub_group(course_id, username)
    elif user_is_an_instructor(user_role):
        # assign the user in 'formgrade-<course_id>' group
        await jupyterhub_api.add_instructor_to_jupyterhub_group(course_id, username)
    # launch the new (?) grader-notebook as a service
    setup_response = await register_new_service(org_name=ORG_NAME, course_id=course_id)

    return authentication
Example #2
0
async def process_resource_link(
    logger: Any,
    course_id: str,
    jwt_body_decoded: Dict[str, Any],
) -> None:
    """
    Executes additional processes with the claims that come only with LtiResourceLinkRequest
    """
    # Values for send-grades functionality
    resource_link = jwt_body_decoded['https://purl.imsglobal.org/spec/lti/claim/resource_link']
    resource_link_title = resource_link['title'] or ''
    course_lineitems = ''
    if (
        'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint' in jwt_body_decoded
        and 'lineitems' in jwt_body_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']
    ):
        course_lineitems = jwt_body_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']['lineitems']
    nbgrader_service = NbGraderServiceHelper(course_id, True)
    nbgrader_service.update_course(lms_lineitems_endpoint=course_lineitems)
    if resource_link_title:
        assignment_name = LTIUtils().normalize_string(resource_link_title)
        logger.debug('Creating a new assignment from the Authentication flow with title %s' % assignment_name)
        # register the new assignment in nbgrader database
        nbgrader_service.register_assignment(assignment_name)
        # create the assignment source directory by calling the grader-setup service
        await create_assignment_source_dir(ORG_NAME, course_id, assignment_name)
Example #3
0
async def process_resource_link_lti_13(
    logger: Any,
    course_id: str,
    jwt_body_decoded: Dict[str, Any],
) -> None:
    """
    Executes additional processes with the claims that come only with LtiResourceLinkRequest
    """
    # Values for send-grades functionality
    resource_link = jwt_body_decoded[
        "https://purl.imsglobal.org/spec/lti/claim/resource_link"]
    resource_link_title = resource_link["title"] or ""
    nbgrader_service = NbGraderServiceHelper(course_id, True)
    if resource_link_title:
        assignment_name = LTIUtils().normalize_string(resource_link_title)
        logger.debug(
            "Creating a new assignment from the Authentication flow with title %s"
            % assignment_name)
        # register the new assignment in nbgrader database
        nbgrader_service.register_assignment(assignment_name)
        # create the assignment source directory by calling the grader-setup service
        await create_assignment_source_dir(ORG_NAME, course_id,
                                           assignment_name)
Example #4
0
class TestNbGraderServiceBaseHelper:
    def setup_method(self, method):
        """
        Setup method to initialize objects/properties used for the tests
        """
        self.course_id = "PS- ONE"
        self.sut = NbGraderServiceHelper(self.course_id)

    def test_course_id_required_otherwise_raises_an_error(self):
        """
        Does the initializer accept empty or none value for course_id?
        """
        with pytest.raises(ValueError):
            NbGraderServiceHelper("")

    def test_course_id_is_normalized_in_the_constructor(self):
        """
        Does the course-id value is normalized?
        """
        assert self.sut.course_id == "ps-one"

    @patch("shutil.chown")
    @patch("pathlib.Path.mkdir")
    @patch("illumidesk.apis.nbgrader_service.Gradebook")
    def test_add_user_to_nbgrader_gradebook_raises_error_when_empty(
            self, mock_gradebook, mock_path_mkdir, mock_chown):
        """
        Does add_user_to_nbgrader_gradebook method accept an empty username, or lms user id?
        """
        with pytest.raises(ValueError):
            self.sut.add_user_to_nbgrader_gradebook(username="",
                                                    lms_user_id="abc123")

        with pytest.raises(ValueError):
            self.sut.add_user_to_nbgrader_gradebook(username="******",
                                                    lms_user_id="")
Example #5
0
    async def authenticate(self, handler: BaseHandler, data: Dict[str, str] = None) -> Dict[str, str]:  # noqa: C901
        """
        LTI 1.1 authenticator which overrides authenticate function from base LTIAuthenticator.
        After validating the LTI 1.1 signuature, this function decodes the dictionary object
        from the request payload to set normalized strings for the course_id, username,
        user role, and lms user id. Once those values are set they are added to the auth_state
        and returned as a dictionary for further processing by hooks defined in jupyterhub_config.

        One or more consumer keys/values must be set in the jupyterhub config with the
        LTIAuthenticator.consumers dict.

        Args:
            handler: JupyterHub's Authenticator handler object. For LTI 1.1 requests, the handler is
              an instance of LTIAuthenticateHandler.
            data: optional data object

        Returns:
            Authentication's auth_state dictionary

        Raises:
            HTTPError if the required values are not in the request
        """
        validator = LTI11LaunchValidator(self.consumers)
        lti_utils = LTIUtils()

        self.log.debug('Original arguments received in request: %s' % handler.request.arguments)

        # extract the request arguments to a dict
        args = lti_utils.convert_request_to_dict(handler.request.arguments)
        self.log.debug('Decoded args from request: %s' % args)

        # get the origin protocol
        protocol = lti_utils.get_client_protocol(handler)
        self.log.debug('Origin protocol is: %s' % protocol)

        # build the full launch url value required for oauth1 signatures
        launch_url = f'{protocol}://{handler.request.host}{handler.request.uri}'
        self.log.debug('Launch url is: %s' % launch_url)

        if validator.validate_launch_request(launch_url, handler.request.headers, args):
            # get the lms vendor to implement optional logic for said vendor
            lms_vendor = ''
            if 'tool_consumer_info_product_family_code' in args and args['tool_consumer_info_product_family_code']:
                lms_vendor = args['tool_consumer_info_product_family_code']

            # We use the course_id to setup the grader service notebook. Since this service
            # runs as a container we need to normalize the string so we can use it
            # as a container name.
            if 'context_label' in args and args['context_label']:
                course_id = args['context_label']
                self.log.debug('Course context_label normalized to: %s' % course_id)
            elif 'context_title' in args and args['context_title']:
                course_id = args['context_title']
                self.log.debug('Course context_title normalized to: %s' % course_id)
            else:
                raise HTTPError(400, 'course_label or course_title not included in the LTI request')

            # Get the user's role, assign to Learner role by default. Roles are sent as institution
            # roles, where the roles' value is <handle>,<full URN>.
            # https://www.imsglobal.org/specs/ltiv1p0/implementation-guide#toc-16
            user_role = 'Learner'
            if 'roles' in args and args['roles']:
                user_role = args['roles'].split(',')[0]
                self.log.debug('User LTI role is: %s' % user_role)
            else:
                raise HTTPError(400, 'User role not included in the LTI request')

            # Assign the user_id. Check the tool consumer (lms) vendor. If canvas use their
            # custom user id extension by default, else use standar lti values.
            username = ''
            if lms_vendor == 'canvas':
                login_id = ''
                user_id = ''
                self.log.debug('TC is a Canvas LMS instance')
                if (
                    'custom_canvas_user_login_id' in args
                    and args['custom_canvas_user_login_id']
                    and 'custom_canvas_user_id' in args
                    and args['custom_canvas_user_id']
                ):
                    custom_canvas_user_login_id = args['custom_canvas_user_login_id']
                    login_id = lti_utils.email_to_username(custom_canvas_user_login_id)
                    self.log.debug('using custom_canvas_user_login_id for username')
                if 'custom_canvas_user_id' in args and args['custom_canvas_user_id']:
                    custom_canvas_user_id = args['custom_canvas_user_id']
                    user_id = lti_utils.email_to_username(custom_canvas_user_id)
                    self.log.debug('using custom_canvas_user_id for username')
                username = f'{login_id}-{user_id}'
            if (
                not username
                and 'lis_person_contact_email_primary' in args
                and args['lis_person_contact_email_primary']
            ):
                email = args['lis_person_contact_email_primary']
                username = lti_utils.email_to_username(email)
                self.log.debug('using lis_person_contact_email_primary for username')
            elif not username and 'lis_person_name_given' in args and args['lis_person_name_given']:
                username = args['lis_person_name_given']
                self.log.debug('using lis_person_name_given for username')
            elif not username and 'lis_person_sourcedid' in args and args['lis_person_sourcedid']:
                username = args['lis_person_sourcedid']
                self.log.debug('using lis_person_sourcedid for username')
            elif not username and 'lis_person_name_family' in args and args['lis_person_name_family']:
                username = args['lis_person_name_family']
                self.log.debug('using lis_person_name_family for username')
            elif not username and 'lis_person_name_full' in args and args['lis_person_name_full']:
                username = args['lis_person_name_full']
                self.log.debug('using lis_person_name_full for username')
            elif not username and 'user_id' in args and args['user_id']:
                username = args['user_id']
            elif not username:
                raise HTTPError(400, 'Unable to get username from request arguments')

            # use the user_id to identify the unique user id, if its not sent with the request
            # then default to the username
            lms_user_id = args['user_id'] if 'user_id' in args else username

            # GRADES-SENDER: fetch the information needed to register assignments within the control file
            # retrieve assignment_name from standard property vs custom lms properties
            assignment_name = ''
            # the next fields must come in args
            if 'custom_canvas_assignment_title' in args and args['custom_canvas_assignment_title']:
                assignment_name = lti_utils.normalize_string(args['custom_canvas_assignment_title'])
            # this requires adding a the assignment_title as a custom parameter in the tool consumer (lms)
            elif 'custom_assignment_title' in args and args['custom_assignment_title']:
                assignment_name = lti_utils.normalize_string(args['custom_assignment_title'])
            elif 'resource_link_title' in args and args['resource_link_title']:
                assignment_name = lti_utils.normalize_string(args['resource_link_title'])
            elif 'resource_link_id' in args and args['resource_link_id']:
                assignment_name = lti_utils.normalize_string(args['resource_link_id'])

            # Get lis_outcome_service_url and lis_result_sourcedid values that will help us to submit grades later
            lis_outcome_service_url = ''
            lis_result_sourcedid = ''

            # the next fields must come in args
            if 'lis_outcome_service_url' in args and args['lis_outcome_service_url']:
                lis_outcome_service_url = args['lis_outcome_service_url']
            if 'lis_result_sourcedid' in args and args['lis_result_sourcedid']:
                lis_result_sourcedid = args['lis_result_sourcedid']
            # only if both values exist we can register them to submit grades later
            if lis_outcome_service_url and lis_result_sourcedid:
                control_file = LTIGradesSenderControlFile(f'/home/grader-{course_id}/{course_id}')
                control_file.register_data(assignment_name, lis_outcome_service_url, lms_user_id, lis_result_sourcedid)
            # Assignment creation
            if assignment_name:
                nbgrader_service = NbGraderServiceHelper(course_id, True)
                self.log.debug(
                    'Creating a new assignment from the Authentication flow with title %s' % assignment_name
                )
                nbgrader_service.register_assignment(assignment_name)
            # ensure the user name is normalized
            username_normalized = lti_utils.normalize_string(username)
            self.log.debug('Assigned username is: %s' % username_normalized)

            return {
                'name': username_normalized,
                'auth_state': {
                    'course_id': course_id,
                    'lms_user_id': lms_user_id,
                    'user_role': user_role,
                },  # noqa: E231
            }
Example #6
0
    def __init__(self, course_id: str, assignment_name: str):
        self.course_id = course_id
        self.assignment_name = assignment_name

        # get nbgrader connection string from env vars
        self.nbgrader_helper = NbGraderServiceHelper(course_id)
Example #7
0
 def setup_method(self, method):
     """
     Setup method to initialize objects/properties used for the tests
     """
     self.course_id = "PS- ONE"
     self.sut = NbGraderServiceHelper(self.course_id)
Example #8
0
 def test_course_id_required_otherwise_raises_an_error(self):
     """
     Does the initializer accept empty or none value for course_id?
     """
     with pytest.raises(ValueError):
         NbGraderServiceHelper("")
Example #9
0
async def setup_course_hook_lti11(
    handler: RequestHandler,
    authentication: Dict[str, str],
) -> Dict[str, str]:
    """
    Calls the microservice to setup up a new course in case it does not exist when receiving
    LTI 1.1 launch requests. The data needed is received from auth_state within authentication object.
    This function assumes that the required k/v's in the auth_state dictionary are available,
    since the Authenticator(s) validates the data beforehand.

    This function requires `Authenticator.enable_auth_state = True` and is intended
    to be used as a post_auth_hook.

    Args:
        handler: the JupyterHub handler object
        authentication: the authentication object returned by the
            authenticator class

    Returns:
        authentication (Required): updated authentication object
    """
    lti_utils = LTIUtils()
    jupyterhub_api = JupyterHubAPI()

    # normalize the name and course_id strings in authentication dictionary
    username = authentication["name"]
    lms_user_id = authentication["auth_state"]["user_id"]
    user_role = authentication["auth_state"]["roles"].split(",")[0]
    course_id = lti_utils.normalize_string(
        authentication["auth_state"]["context_label"])
    nb_service = NbGraderServiceHelper(course_id, True)

    # register the user (it doesn't matter if it is a student or instructor) with her/his lms_user_id in nbgrader
    nb_service.add_user_to_nbgrader_gradebook(username, lms_user_id)
    # TODO: verify the logic to simplify groups creation and membership
    if user_is_a_student(user_role):
        try:
            # assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook
            await jupyterhub_api.add_student_to_jupyterhub_group(
                course_id, username)
        except AddJupyterHubUserException as e:
            logger.error(
                "An error when adding student username: %s to course_id: %s with exception %s",
                (username, course_id, e),
            )
    elif user_is_an_instructor(user_role):
        try:
            # assign the user in 'formgrade-<course_id>' group
            await jupyterhub_api.add_instructor_to_jupyterhub_group(
                course_id, username)
        except AddJupyterHubUserException as e:
            logger.error(
                "An error when adding instructor username: %s to course_id: %s with exception %s",
                (username, course_id, e),
            )

    # launch the new grader-notebook as a service
    try:
        _ = await register_new_service(org_name=ORG_NAME, course_id=course_id)
    except Exception as e:
        logger.error(
            "Unable to launch the shared grader notebook with exception %s", e)

    return authentication