Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

The first version of the LTI1.3 protocol implementation #1

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
43 changes: 43 additions & 0 deletions examples/app.py
@@ -0,0 +1,43 @@
import os

from flask import Flask
from controllers import (
login,
launch,
get_jwks,
create_test,
get_grade,
set_grade,
get_current_grade,
set_grade_with_current_lineitem,
)


app = Flask(__name__)


app.config["SECRET_KEY"] = os.urandom(16).hex()
app.config["SESSION_COOKIE_NAME"] = "mimilti_session"
app.add_url_rule("/login", methods=["GET", "POST"], view_func=login)
app.add_url_rule("/launch", methods=["GET", "POST"], view_func=launch)
app.add_url_rule("/jwks", methods=["GET", "POST"], view_func=get_jwks)
app.add_url_rule("/set_grade", methods=["GET", "POST"], view_func=set_grade)
app.add_url_rule("/create_test", methods=["GET", "POST"], view_func=create_test)
app.add_url_rule("/get_grade", methods=["GET", "POST"], view_func=get_grade)
app.add_url_rule(
"/get_current_grade", methods=["GET", "POST"], view_func=get_current_grade
)
app.add_url_rule(
"/set_grade_with_current_lineitem",
methods=["GET", "POST"],
view_func=set_grade_with_current_lineitem,
)


@app.route("/")
def index():
return ""


if __name__ == "__main__":
app.run(port=9002)
1 change: 1 addition & 0 deletions examples/config/config.json
@@ -0,0 +1 @@
{"kid": "5r03KaCiqaQBVD8zwDu0mHmd0WXxxwBAoG67SpSyD50", "issuers": {"http://localhost/moodle": {"login_url": "http://localhost/moodle/mod/lti/auth.php", "token_url": "http://localhost/moodle/mod/lti/token.php", "tools": [{"aud": "DS7jNSEoKQPjFCk", "jwks_endpoint": "http://localhost/moodle/mod/lti/certs.php"}, {"aud": "URw5NjQzGdD2KdE", "jwks_endpoint": "http://localhost/moodle/mod/lti/certs.php"}]}}}
1 change: 1 addition & 0 deletions examples/config/jwk.json
@@ -0,0 +1 @@
{"keys": [{"e": "AQAB", "kid": "5r03KaCiqaQBVD8zwDu0mHmd0WXxxwBAoG67SpSyD50", "kty": "RSA", "n": "o-HxDT6GfZgI5t0ovfd6Sn8Gh0-rxJ86Deun2Nz_EI1x64aO-saigXFoXI04KzoIE-CKUg9aGVPYEhKj59lY5l4EQBkyRcs2nawKnQqQkqWDmCydkfMMgvYEQLzoQVkg-f6m2xpyfK3Q6OCh97-B8U4Tx2jiuF1OCQnsVV1-AJ0JCascxG_3dzXHjTkFARuCztb2NzT6SbKnWpeTGPjiq3uZlwESpo4-nZpvc1GYdELGmVrJ5RGmqDrNRABlaoe-3FipIHnBoD6HUTCITHN3mXCWltjEeUiqFGphDccIK8Ek6GiSUxTNcTJvsW8uDPobg-5YhbG-G-6DtgPgeM_qfolxIjpxK4qBD5NlHbL6IU9rDzf9bcUXbwedvhmNJmBH91am9H3AVeBeFdO1wK8KL9-L5o_6guHGelJk-Rn8eXlB8O-1d4ENQb963nOPS1NyOOPOdgqqfdFSPXEWfsvHC3puG-Ad3D8bexrPX2XHRWavT9yJRbTvw-pWzFFIA-a3Dz1pba4VqGpOG08rLT-BhM2XLy7lmaBssoCAniXGq913fD3Qndn8HAIVNzFJBMJfNYwP866frmMD6N3cdgk1zoQ5iu6G-HQzR_inybpbR7OiPHMQsiyu0HlJXJmt3N0qaBAAcHZym4UROO0e3fg5kqtP-jKxGXeGCkrAxNiXe1c", "alg": "RS256", "use": "sig"}]}
51 changes: 51 additions & 0 deletions examples/config/private.key
@@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
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
-----END RSA PRIVATE KEY-----
14 changes: 14 additions & 0 deletions examples/config/public.key
@@ -0,0 +1,14 @@
-----BEGIN PUBLIC KEY-----
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAo+HxDT6GfZgI5t0ovfd6
Sn8Gh0+rxJ86Deun2Nz/EI1x64aO+saigXFoXI04KzoIE+CKUg9aGVPYEhKj59lY
5l4EQBkyRcs2nawKnQqQkqWDmCydkfMMgvYEQLzoQVkg+f6m2xpyfK3Q6OCh97+B
8U4Tx2jiuF1OCQnsVV1+AJ0JCascxG/3dzXHjTkFARuCztb2NzT6SbKnWpeTGPji
q3uZlwESpo4+nZpvc1GYdELGmVrJ5RGmqDrNRABlaoe+3FipIHnBoD6HUTCITHN3
mXCWltjEeUiqFGphDccIK8Ek6GiSUxTNcTJvsW8uDPobg+5YhbG+G+6DtgPgeM/q
folxIjpxK4qBD5NlHbL6IU9rDzf9bcUXbwedvhmNJmBH91am9H3AVeBeFdO1wK8K
L9+L5o/6guHGelJk+Rn8eXlB8O+1d4ENQb963nOPS1NyOOPOdgqqfdFSPXEWfsvH
C3puG+Ad3D8bexrPX2XHRWavT9yJRbTvw+pWzFFIA+a3Dz1pba4VqGpOG08rLT+B
hM2XLy7lmaBssoCAniXGq913fD3Qndn8HAIVNzFJBMJfNYwP866frmMD6N3cdgk1
zoQ5iu6G+HQzR/inybpbR7OiPHMQsiyu0HlJXJmt3N0qaBAAcHZym4UROO0e3fg5
kqtP+jKxGXeGCkrAxNiXe1cCAwEAAQ==
-----END PUBLIC KEY-----
184 changes: 184 additions & 0 deletions examples/controllers.py
@@ -0,0 +1,184 @@
import functools
import os
import pathlib
from collections.abc import Callable
from datetime import datetime, timedelta

from flask import (
jsonify,
redirect,
request,
url_for,
send_from_directory,
)


from mimilti.grade import LineItem, GradeService, Progress, CompletedFullyGradedProgress
from mimilti.login import LtiRequestObject
from mimilti.data_storage import SessionDataStorage
from mimilti.config import Config, RsaKey
from mimilti.roles import (
ContextInstructorRole,
RoleService,
)
from mimilti.lms_pool import LmsRequestsPool

public_key_path = os.path.join(pathlib.Path(__file__).parent, "config/public.key")
private_key_path = os.path.join(pathlib.Path(__file__).parent, "config/private.key")
public_json_path = os.path.join(pathlib.Path(__file__).parent, "config/config.json")

key = RsaKey(private_key_path, public_key_path)
config = Config(public_json_path, key)
LmsRequestsPool.start(config)


def once[
T, **P
](func: Callable[(P.args, P.kwargs), T]) -> Callable[(P.args, P.kwargs), T]:
is_called = False
result = None

@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
nonlocal is_called
nonlocal result
if not is_called:
is_called = True
result = func(*args, **kwargs)
return result
else:
return result

return wrapper


@once
def get_grade_service():
return GradeService(SessionDataStorage(), config, refresh=True)


def get_jwks():
return send_from_directory(
os.path.join(pathlib.Path(__file__).parent, "config/"), "jwk.json"
)


def login():
if request.method == "POST":
session_service = SessionDataStorage()
try:
request_object = LtiRequestObject(request.form, session_service, config)
except Exception as e:
return jsonify({"error": str(e)}), 401

redirect_url = request_object.get_redirect_url()
issuer = request_object.get_issuer()

next_url = request.args.get("next")

session_service.iss = issuer
session_service.aud = request_object.get_client_id()

if next_url:
session_service.save_param_to_session(next_url, "next_url")
else:
session_service.remove_param_from_session("next_url")

return redirect(redirect_url)


def launch():
if request.method == "POST":
session_data_service = SessionDataStorage()

request_object = LtiRequestObject(request.form, session_data_service, config)

try:
data = request_object.get_token()
session_data_service.update_params(data)
config.add_tool(session_data_service.iss, session_data_service.aud)
except Exception as e:
return jsonify({"error": str(e)}), 401

# you login logic

if (next_url := session_data_service.get_param("next_url")) is None:
return redirect(url_for("create_test"))

else:
session_data_service.remove_param_from_session("next_url")
return redirect(next_url)


def get_role_service():
data_service = SessionDataStorage()
role_service = RoleService(data_service)
return role_service


@get_role_service().lti_role_accepted(ContextInstructorRole)
def create_test():

test_guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11"
test_label = "test ugugugu"
test_tag = "test tag"
test_maximum_score = 100
test_start_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
test_end_time = (datetime.now() + timedelta(seconds=3600)).strftime(
"%Y-%m-%dT%H:%M:%S"
)

data_service = SessionDataStorage()
grade_service = GradeService(data_service, config)
lineitems = LineItem(
id=None,
label=test_label,
score_maximum=test_maximum_score,
resource_id=test_guid,
tag=test_tag,
start_date_time=test_start_time,
end_date_time=test_end_time,
)

grade_service.create_or_set_lineitem(lineitems)
return redirect(url_for("get_grade"))


def get_grade():
guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11"
grade_service = get_grade_service()
print(grade_service.get_grade(guid))
return ""


def get_current_grade():
grade_service = get_grade_service()
print(grade_service.get_grade())
return ""


def set_grade():
guid = "7262dd22-ae2b-4a88-8d29-dfcf728b2c11"
grade_service = get_grade_service()
progress = CompletedFullyGradedProgress(
score_given=60,
score_maximum=100,
comment="comment",
timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"),
)

grade_service.set_grade(progress, guid)
return ""


def set_grade_with_current_lineitem():
grade_service = get_grade_service()
progress = CompletedFullyGradedProgress(
score_given=60,
score_maximum=100,
comment="comment",
timestamp=datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ"),
)

grade_service.set_grade(progress)
return ""
Empty file added mimilti/__init__.py
Empty file.