Skip to content
This repository was archived by the owner on Sep 5, 2023. It is now read-only.

Automated assignment creation with LTI 1.3 #260

Merged
merged 16 commits into from
Aug 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/illumidesk/apis/nbgrader_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import logging
from pathlib import Path
import os
import shutil
import sys

from nbgrader.api import Assignment, Course, Gradebook
from nbgrader.api import InvalidEntry

logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logger = logging.getLogger(__name__)


class NbGraderServiceHelper:
def __init__(self, course_id: str):
if not course_id:
raise ValueError('course_id missing')
self.course_id = course_id
self.uid = int(os.environ.get('NB_UID') or '10001')
self.gid = int(os.environ.get('NB_GID') or '100')
grader_name = f'grader-{course_id}'
self.course_dir = f'/home/{grader_name}/{course_id}'

self.gradebook_path = Path(self.course_dir, 'gradebook.db')
# make sure the gradebook path exists
self.gradebook_path.parent.mkdir(exist_ok=True, parents=True)
logger.debug('Gradebook path is %s' % self.gradebook_path)
logger.debug("creating gradebook instance")
self.gb = Gradebook(f'sqlite:///{self.gradebook_path}', course_id=self.course_id)
logger.debug(
'Changing or making sure the gradebook directory permissions (with path %s) to %s:%s '
% (self.gradebook_path, self.uid, self.gid)
)
shutil.chown(str(self.gradebook_path), user=self.uid, group=self.gid)

def update_course(self, **kwargs) -> None:
with Gradebook(f'sqlite:///{self.gradebook_path}', course_id=self.course_id) as gb:
gb.update_course(self.course_id, **kwargs)

def get_course(self) -> Course:
with Gradebook(f'sqlite:///{self.gradebook_path}', course_id=self.course_id) as gb:
course = gb.check_course(self.course_id)
logger.debug(f'course got from db:{course}')
return course

def create_assignment_in_nbgrader(self, assignment_name: str, **kwargs: dict) -> Assignment:
"""
Adds an assignment to nbgrader database

Args:
assignment_name: The assingment's name
Raises:
InvalidEntry: when there was an error adding the assignment to the database
"""
if not assignment_name:
raise ValueError('assignment_name missing')
assignment = None
with Gradebook(f'sqlite:///{self.gradebook_path}', course_id=self.course_id) as gb:
try:
assignment = gb.update_or_create_assignment(assignment_name, **kwargs)
logger.debug('Added assignment %s to gradebook' % assignment_name)
sourcedir = os.path.abspath(Path(self.course_dir, 'source', assignment_name))
if not os.path.isdir(sourcedir):
logger.debug('Creating source dir %s for the assignment %s' % (sourcedir, assignment_name))
os.makedirs(sourcedir)
logger.debug('Fixing folder permissions for %s' % sourcedir)
shutil.chown(str(sourcedir), user=self.uid, group=self.gid)
except InvalidEntry as e:
logger.debug('Error during adding assignment to gradebook: %s' % e)
return assignment
24 changes: 17 additions & 7 deletions src/illumidesk/authenticators/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from illumidesk.apis.jupyterhub_api import JupyterHubAPI
from illumidesk.apis.announcement_service import AnnouncementService
from illumidesk.apis.nbgrader_service import NbGraderServiceHelper
from illumidesk.apis.setup_course_service import make_rolling_update
from illumidesk.apis.setup_course_service import register_new_service

Expand Down Expand Up @@ -68,11 +69,12 @@ async def setup_course_hook(
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 is a student or instructor) with her/his lms_user_id in nbgrader
await jupyterhub_api.add_user_to_nbgrader_gradebook(course_id, username, lms_user_id)
# TODO: verify the logic to simplify groups creation and membership
if user_role == 'Student' or user_role == 'Learner':
# assign the user to 'nbgrader-<course_id>' group in jupyterhub and gradebook
await jupyterhub_api.add_student_to_jupyterhub_group(course_id, username)
await jupyterhub_api.add_user_to_nbgrader_gradebook(course_id, username, lms_user_id)
elif user_role == 'Instructor':
# assign the user in 'formgrade-<course_id>' group
await jupyterhub_api.add_instructor_to_jupyterhub_group(course_id, username)
Expand Down Expand Up @@ -244,7 +246,6 @@ async def authenticate(self, handler: BaseHandler, data: Dict[str, str] = None)
control_file.register_data(
assignment_name, lis_outcome_service_url, lms_user_id, lis_result_sourcedid
)

# ensure the user name is normalized
username_normalized = lti_utils.normalize_string(username)
self.log.debug('Assigned username is: %s' % username_normalized)
Expand Down Expand Up @@ -380,12 +381,22 @@ async def authenticate( # noqa: C901
self.log.debug('user_role is %s' % user_role)

lms_user_id = jwt_decoded['sub'] if 'sub' in jwt_decoded else username
# Values for the send-grades functionality
# Values for send-grades functionality
resource_link = jwt_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_decoded:
course_lineitems = jwt_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint'].get(
'lineitems'
if (
'https://purl.imsglobal.org/spec/lti-ags/claim/endpoint' in jwt_decoded
and 'lineitems' in jwt_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']
):
course_lineitems = jwt_decoded['https://purl.imsglobal.org/spec/lti-ags/claim/endpoint']['lineitems']
nbgrader_service = NbGraderServiceHelper(course_id)
nbgrader_service.update_course(lms_lineitems_endpoint=course_lineitems)
if resource_link_title:
self.log.debug(
'Creating a new assignment from the Authentication flow with title %s' % resource_link_title
)
nbgrader_service.create_assignment_in_nbgrader(resource_link_title)

# ensure the user name is normalized
username_normalized = lti_utils.normalize_string(username)
Expand All @@ -397,7 +408,6 @@ async def authenticate( # noqa: C901
'course_id': course_id,
'user_role': user_role,
'workspace_type': workspace_type,
'course_lineitems': course_lineitems,
'lms_user_id': lms_user_id,
}, # noqa: E231
}
10 changes: 5 additions & 5 deletions src/illumidesk/grades/handlers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json


from illumidesk.authenticators.authenticator import LTI11Authenticator
from illumidesk.grades import exceptions
from illumidesk.grades.senders import LTI13GradeSender
Expand Down Expand Up @@ -40,15 +41,14 @@ async def post(self, course_id: str, assignment_name: str) -> None:
if isinstance(self.authenticator, LTI11Authenticator) or self.authenticator is LTI11Authenticator:
lti_grade_sender = LTIGradeSender(course_id, assignment_name)
else:
auth_state = await self.current_user.get_auth_state()
self.log.debug(f'auth_state from current_user:{auth_state}')
lti_grade_sender = LTI13GradeSender(course_id, assignment_name, auth_state)
lti_grade_sender = LTI13GradeSender(course_id, assignment_name)
try:
await lti_grade_sender.send_grades()
except exceptions.GradesSenderCriticalError:
raise web.HTTPError(400, 'There was an critical error, please check logs.')
except exceptions.AssignmentWithoutGradesError:
raise web.HTTPError(400, 'There are no grades yet to submit')
except exceptions.GradesSenderMissingInfoError:
raise web.HTTPError(400, 'Impossible to send grades. There are missing values, please check logs.')
except exceptions.GradesSenderMissingInfoError as e:
self.log.error(f'There are missing values.{e}')
raise web.HTTPError(400, f'Impossible to send grades. There are missing values, please check logs.{e}')
self.write(json.dumps({"success": True}))
54 changes: 28 additions & 26 deletions src/illumidesk/grades/senders.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from nbgrader.api import Gradebook, MissingEntry

from tornado.httpclient import AsyncHTTPClient
from illumidesk.apis.nbgrader_service import NbGraderServiceHelper

from illumidesk.lti13.auth import get_lms_access_token

Expand Down Expand Up @@ -143,33 +144,31 @@ async def send_grades(self) -> None:


class LTI13GradeSender(GradesBaseSender):
def __init__(self, course_id: str, assignment_name: str, auth_state: dict):
def __init__(self, course_id: str, assignment_name: str):
"""
Creates a new class to help us to send grades saved in the nbgrader gradebook (sqlite) back to the LMS

For simplify the submission we're using the lineitem_id (that is a url) obtained in authentication flow and it indicates us where send the scores
So the assignment item in the database should contains the 'lms_lineitem_id' with something like /api/lti/courses/:course_id/line_items/:line_item_id
Args:
course_id: It's the course label obtained from lti claims
assignment_name: the asignment name used on the nbgrader console
auth_state: It's a dictionary with the auth state of the user. Saved when user logged in.
The required key is 'course_lineitems' (obtained from the https://purl.imsglobal.org/spec/lti-ags/claim/endpoint claim)
and its value is something like 'http://canvas.instructure.com/api/lti/courses/1/line_items'
"""
super(LTI13GradeSender, self).__init__(course_id, assignment_name)
if auth_state is None or 'course_lineitems' not in auth_state:
logger.info('The key "course_lineitems" is missing in the user auth_state and it is required')
raise GradesSenderMissingInfoError()

logger.info(f'User auth_state received from SenderHandler: {auth_state}')
self.lineitems_url = auth_state['course_lineitems']
self.private_key_path = os.environ.get('LTI13_PRIVATE_KEY')
self.lms_token_url = os.environ['LTI13_TOKEN_URL']
self.lms_client_id = os.environ['LTI13_CLIENT_ID']
# retrieve the course entity from nbgrader-gradebook
nbgrader_service = NbGraderServiceHelper(course_id)
course = nbgrader_service.get_course()
self.course = course

async def _get_line_item_info_by_assignment_name(self) -> str:
client = AsyncHTTPClient()
resp = await client.fetch(self.lineitems_url, headers=self.headers)
resp = await client.fetch(self.course.lms_lineitems_endpoint, headers=self.headers)
items = json.loads(resp.body)
logger.debug(f'LineItems got from {self.lineitems_url} -> {items}')
logger.debug(f'LineItems retrieved: {items}')
if not items or isinstance(items, list) is False:
raise GradesSenderMissingInfoError(f'No line-items were detected for this course: {self.course_id}')
lineitem_matched = None
Expand Down Expand Up @@ -212,18 +211,21 @@ async def send_grades(self):
client = AsyncHTTPClient()
self.headers.update({'Content-Type': 'application/vnd.ims.lis.v1.score+json'})
for grade in nbgrader_grades:
score = float(grade['score'])
data = {
'timestamp': datetime.now().isoformat(),
'userId': grade['lms_user_id'],
'scoreGiven': score,
'scoreMaximum': score_maximum,
'gradingProgress': 'FullyGraded',
'activityProgress': 'Completed',
'comment': '',
}
logger.info(f'data used to sent scores: {data}')

url = lineitem_info['id'] + '/scores'
logger.debug(f'URL for lineitem grades submission {url}')
await client.fetch(url, body=json.dumps(data), method='POST', headers=self.headers)
try:
score = float(grade['score'])
data = {
'timestamp': datetime.now().isoformat(),
'userId': grade['lms_user_id'],
'scoreGiven': score,
'scoreMaximum': score_maximum,
'gradingProgress': 'FullyGraded',
'activityProgress': 'Completed',
'comment': '',
}
logger.info(f'data used to sent scores: {data}')

url = lineitem_info['id'] + '/scores'
logger.debug(f'URL for grades submission {url}')
await client.fetch(url, body=json.dumps(data), method='POST', headers=self.headers)
except Exception as e:
logger.error(f"Something went wrong by sending grader for {grade['lms_user_id']}.{e}")
Loading