Skip to content
This repository has been archived by the owner on Jun 30, 2024. It is now read-only.

Commit

Permalink
Merge 832a33e into 35736f8
Browse files Browse the repository at this point in the history
  • Loading branch information
bnmnetp committed Aug 13, 2020
2 parents 35736f8 + 832a33e commit 9476789
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 68 deletions.
2 changes: 1 addition & 1 deletion controllers/books.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def dummy():
raise HTTP(404)

# See if this is static content. By default, the Sphinx static directory names are ``_static`` and ``_images``.
if request.args(1) in ["_static", "_images"]:
if request.args(1) in ["_static", "_images"] or book_path.endswith(("css","png","jpg")):
# See the `response <http://web2py.com/books/default/chapter/29/04/the-core#response>`_.
# Warning: this is slow. Configure a production server to serve this statically.
return response.stream(book_path, 2 ** 20, request=request)
Expand Down
305 changes: 251 additions & 54 deletions controllers/lti.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,39 @@
# *************************************************
# |docname| - LTI Endpoint for integrating with LMS
# *************************************************
#
# Imports
# =======
# These are listed in the order prescribed by `PEP 8
# <http://www.python.org/dev/peps/pep-0008/#imports>`_.
#
# Standard library
# ----------------
import uuid
import json
import html
import time

# Third-party imports
# -------------------
import oauth2

# Local application imports
# -------------------------
from rs_grading import _try_to_send_lti_grade
import oauth2


# For some reason, URL query parameters are being processed twice by Canvas and returned as a list, like [23, 23]. So, just take the first element in the list.
def _param_converter(param):
return param[0] if isinstance(param, list) else param


# Main LTI Launch Endpoint
# ------------------------
def index():

# Basic processing of the LTI request starts here
# this first block is about getting the user information provided by the LMS
myrecord = None
consumer = None
masterapp = None
Expand All @@ -20,6 +43,9 @@ def index():
last_name = request.vars.get("lis_person_name_family", None)
first_name = request.vars.get("lis_person_name_given", None)
full_name = request.vars.get("lis_person_name_full", None)
message_type = request.vars.get("lti_message_type")
course_id = _param_converter(request.vars.get("custom_course_id", None))

if full_name and not last_name:
names = full_name.strip().split()
last_name = names[-1]
Expand Down Expand Up @@ -76,6 +102,9 @@ def index():
email = email or (user_id + "@junk.com")
userinfo["email"] = email

# Now we need to get some security info
# oauth_consumer_key

key = request.vars.get("oauth_consumer_key", None)
if key is not None:
myrecord = db(db.lti_keys.consumer == key).select().first()
Expand Down Expand Up @@ -130,6 +159,9 @@ def index():
masterapp=masterapp,
)
consumer = None
###############################################################################
# I think everything from the beginning to here could/should be refactored into
# a validate function. Or make use of the lti package

# Time to create / update / login the user
if userinfo and (consumer is not None):
Expand Down Expand Up @@ -157,7 +189,6 @@ def index():
masterapp=masterapp,
)
# user exists; make sure course name and id are set based on custom parameters passed, if this is for runestone. As noted for ``assignment_id``, parameters are passed as a two-element list.
course_id = _param_converter(request.vars.get("custom_course_id", None))
if course_id:
user["course_id"] = course_id
user["course_name"] = getCourseNameFromId(
Expand Down Expand Up @@ -203,65 +234,231 @@ def index():

auth.login_user(user)

if assignment_id:
if message_type == "ContentItemSelectionRequest":
return _provide_assignment_list(course_id, consumer)

elif assignment_id:
# If the assignment is released, but this is the first time a student has visited the assignment, auto-upload the grade.
assignment = (
db(db.assignments.id == assignment_id)
.select(db.assignments.released)
.first()
)
grade = (
db(
(db.grades.auth_user == user.id)
& (db.grades.assignment == assignment_id)
)
.select(db.grades.lis_result_sourcedid, db.grades.lis_outcome_url)
.first()
)
send_grade = (
assignment
and assignment.released
and grade
and not grade.lis_result_sourcedid
and not grade.lis_outcome_url
)
_launch_assignment(assignment_id, user, result_source_did, outcome_url)

# save the guid and url for reporting back the grade
db.grades.update_or_insert(
(db.grades.auth_user == user.id) & (db.grades.assignment == assignment_id),
elif practice:
_launch_practice(outcome_url, result_source_did, user, course_id)

# else just redirect to the book index
redirect(get_course_url("index.html"))


def _launch_practice(outcome_url, result_source_did, user, course_id):
if outcome_url and result_source_did:
db.practice_grades.update_or_insert(
(db.practice_grades.auth_user == user.id),
auth_user=user.id,
assignment=assignment_id,
lis_result_sourcedid=result_source_did,
lis_outcome_url=outcome_url,
course_name=getCourseNameFromId(course_id),
)
if send_grade:
_try_to_send_lti_grade(user.id, assignment_id)

redirect(
URL("assignments", "doAssignment", vars={"assignment_id": assignment_id})
else: # don't overwrite outcome_url and result_source_did
db.practice_grades.update_or_insert(
(db.practice_grades.auth_user == user.id),
auth_user=user.id,
course_name=getCourseNameFromId(course_id),
)
redirect(
URL(
"assignments",
"settz_then_practice",
vars={"course_name": user["course_name"]},
)
)

elif practice:
if outcome_url and result_source_did:
db.practice_grades.update_or_insert(
(db.practice_grades.auth_user == user.id),
auth_user=user.id,
lis_result_sourcedid=result_source_did,
lis_outcome_url=outcome_url,
course_name=getCourseNameFromId(course_id),
)
else: # don't overwrite outcome_url and result_source_did
db.practice_grades.update_or_insert(
(db.practice_grades.auth_user == user.id),
auth_user=user.id,
course_name=getCourseNameFromId(course_id),
)
redirect(
URL(
"assignments",
"settz_then_practice",
vars={"course_name": user["course_name"]},
)

def _launch_assignment(assignment_id, user, result_source_did, outcome_url):
assignment = (
db(db.assignments.id == assignment_id).select(db.assignments.released).first()
)
grade = (
db((db.grades.auth_user == user.id) & (db.grades.assignment == assignment_id))
.select(db.grades.lis_result_sourcedid, db.grades.lis_outcome_url)
.first()
)
send_grade = (
assignment
and assignment.released
and grade
and not grade.lis_result_sourcedid
and not grade.lis_outcome_url
)

# save the guid and url for reporting back the grade
db.grades.update_or_insert(
(db.grades.auth_user == user.id) & (db.grades.assignment == assignment_id),
auth_user=user.id,
assignment=assignment_id,
lis_result_sourcedid=result_source_did,
lis_outcome_url=outcome_url,
)
if send_grade:
_try_to_send_lti_grade(user.id, assignment_id)

redirect(URL("assignments", "doAssignment", vars={"assignment_id": assignment_id}))


def _provide_assignment_list(course_id, consumer):
"""Gather all of the assignments for this course package them up
per https://www.imsglobal.org/specs/lticiv1p0/specification
and return a form.
This form is then auto-submitted by javascript
The key element of the form is the content_items structure which should look like this:
.. code-block::
{
"@context" : "http://purl.imsglobal.org/ctx/lti/v1/ContentItem",
"@graph" : [
{ "@type" : "LtiLinkItem",
"@id" : ":item2",
"icon" : { OPTIONAL
"@id" : "http://tool.provider.com/icons/small.png",
"width" : 50,
"height" : 50
},
"thumbnail" : { OPTIONAL
"@id" : "http://tool.provider.com/images/thumb.jpg",
"width" : 100,
"height" : 150
},
"title" : "Open sIMSon application",
"text" : "The &lt;em&gt;sIMSon&lt;/em&gt; application provides a collaborative space for developing semantic modelling skills.",
"mediaType" : "application/vnd.ims.lti.v1.ltilink",
"custom" : {
"level" : "novice",
"mode" : "interactive"
},
},
]
}
keys are to include custom parameters for course_id and assignment_id
using mediaType as specified will allow the TC to use the usual LTI
launch mechanism
"""
rdict = {}
rdict["oauth_timestamp"] = str(int(time.time()))
rdict["oauth_nonce"] = str(uuid.uuid1().int)
rdict["oauth_consumer_key"] = consumer.key
rdict["oauth_signature_method"] = "HMAC-SHA1"
rdict["lti_message_type"] = "ContentItemSelection"
rdict["lti_version"] = "LTI-1p0"
rdict["oauth_version"] = "1.0"
rdict["oauth_callback"] = "about:blank"
extra_data = request.vars.get("data", None)
if extra_data:
rdict["data"] = extra_data

return_url = request.vars.get("content_item_return_url")
# return_url = "http://dev.runestoneinteractive.org/runestone/lti/fakestore"

query_res = db(db.assignments.course == course_id).select(
orderby=~db.assignments.duedate
)
result = {
"@context": "http://purl.imsglobal.org/ctx/lti/v1/ContentItem",
"@graph": [],
}
if query_res:
for assignment in query_res:
item = {
"@type": "LtiLinkItem",
"mediaType": "application/vnd.ims.lti.v1.ltilink",
"@id": assignment.id,
"title": assignment.name,
"text": assignment.description,
"custom": {
"custom_course_id": course_id,
"assignment_id": assignment.id,
},
}
result["@graph"].append(item)

result = json.dumps(result)
rdict["content_items"] = result
# response.view = "/srv/web2py/applications/runestone/views/lti/store.html"
# req = oauth2.Request("post", return_url, rdict, is_form_encoded=True)
req = oauth2.Request.from_consumer_and_token(
consumer,
token=None,
http_method="POST",
http_url=return_url,
parameters=rdict,
is_form_encoded=True,
)
req.sign_request(oauth2.SignatureMethod_HMAC_SHA1(), consumer, None)
rdict["return_url"] = return_url
rdict["oauth_signature"] = req["oauth_signature"].decode("utf8")
rdict["content_items"] = html.escape(result)
tplate = """
<!DOCTYPE html>
<html>
<body>
<form name="storeForm" action="{return_url}" method="post" encType="application/x-www-form-urlencoded">
<input type="hidden" name="lti_message_type" value="ContentItemSelection" />
<input type="hidden" name="lti_version" value="LTI-1p0" />
<input type="hidden" name="content_items" value="{content_items}" />
"""
tplate += (
""" <input type="hidden" name="data" value="{data}" /> """
if extra_data
else ""
)
tplate += """
<input type="hidden" name="oauth_version" value="1.0" />
<input type="hidden" name="oauth_nonce" value="{oauth_nonce}" />
<input type="hidden" name="oauth_timestamp" value="{oauth_timestamp}" />
<input type="hidden" name="oauth_consumer_key" value="{oauth_consumer_key}" />
<input type="hidden" name="oauth_callback" value="about:blank" />
<input type="hidden" name="oauth_signature_method" value="HMAC-SHA1" />
<input type="hidden" name="oauth_signature" value="{oauth_signature}" />
</form>
"""
tplate = tplate.format(**rdict)

redirect(get_course_url("index.html"))
scpt = """
<script type="text/javascript">
window.onload=function(){
var auto = setTimeout(function(){ submitform(); }, 1000);
function submitform(){
console.log(document.forms["storeForm"]);
document.forms["storeForm"].submit();
}
}
</script>
</body>
</html>
"""
return tplate + scpt


def fakestore():
# define this function just to show what is coming through
# I'm going to keep this around as it may be useful for future debugging.
content = request.vars.get("content_items")
consumer = oauth2.Consumer("bnm.runestone", "supersecret")
return_url = "http://dev.runestoneinteractive.org/runestone/lti/fakestore"
d = dict(request.vars)
req = oauth2.Request.from_consumer_and_token(
consumer,
token=None,
http_method="POST",
http_url=return_url,
parameters=d,
is_form_encoded=True,
)
req.sign_request(oauth2.SignatureMethod_HMAC_SHA1(), consumer, None)

for k, v in req.items():
print(f"{k} : {v}")
print(req.method)
print(req.normalized_url)
print(req.get_normalized_parameters())

return f" sent sig = {d['oauth_signature']} computed sig {req['oauth_signature'].decode('utf8')} {d}"
2 changes: 2 additions & 0 deletions models/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ def set_latex_preamble(base_course: str):

request.admin_mtime = str(mtime)

response.headers["X-Frame-Options"] = "ALLOW"


def check_for_donate_or_build(field_dict, id_of_insert):
if "donate" in request.vars:
Expand Down
Loading

0 comments on commit 9476789

Please sign in to comment.