Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Recap

Related Posts

Part 1

  • Goals and Assumptions.
  • Initial Setup of the Flask backend.

Part 2

  • Implement LTI-related routes in Flask.
  • Do my best to explain the abstraction that’s happening with PyLTI1p3

Part 3

  • Question data – JSON structure.
  • Revisit project directories.
  • Use create-react-app to start a new React.js front-end project.
  • Install dependencies: Tailwind.
  • Start building out the initial App.js with the proper hooks and event handler. A single page app (SPA), very simple, so we’re not venturing outside this one file for our single quiz component.

Part 4

  • Create the Flask template and arguments to be passed to the frontend.
  • Build the React.js frontend and stage static files.

Part 5

  • Create a Python module to contain the initial set of questions.
  • Create Flask API routes with HTTP request definitions.
  • Create a separate Python module for the database methods. Creation of sqlite database and initial loading of questions.
  • Final update to the frontend to use the Flask API.

Github Repos:

Frontend (React, javascript): https://github.com/eharvey71/react-simple-quiz

Backend (Flask, Python): https://github.com/eharvey71/lti_example_quiz_react_flask

Resources:

Reference Project: https://github.com/dmitry-viskov/pylti1.3-flask-example

PyLTI1p3 for implementing LTI 1.3/Advantage in Python: https://pypi.org/project/PyLTI1p3/

LTI 1.3 Core Specification: https://www.imsglobal.org/spec/lti/v1p3

Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Part 5

Create a default questions module

In our Flask LTI project, let’s create a separate module for our questions in JSON format. Alongside the app.py, create a questions.py file and add as many questions as you want, in this format which is essentially a python list of dictionaries:

questions = [
{
    "questionText": "Where is the Taj Mahal?",
    "answerOptions": [
        { 'answerText': 'Agra', 'isCorrect': True },
        { 'answerText': 'New Delhi', 'isCorrect': False },
        { 'answerText': 'Mumbai', 'isCorrect:': False },
        { 'answerText': 'Chennai', 'isCorrect': False }
    ]
},
{
    "questionText": 'Where is the Great Wall of China?',
    "answerOptions": [
        {'answerText': 'China', 'isCorrect': True },
        {'answerText': 'Beijing', 'isCorrect': False },
        {'answerText': 'Shanghai', 'isCorrect': False },
        {'answerText': 'Tianjin', 'isCorrect': False }
    ]
},
{
    "questionText": "How many countries in the world?",
    "answerOptions": [
        { "answerText": "120", 'isCorrect': False },
        { "answerText": "183", 'isCorrect': False },
        { "answerText": "170", 'isCorrect': False },
        { "answerText": "195", 'isCorrect': True },
   ]
 },
{
    "questionText": "How many continents are there?",
    "answerOptions": [
        { "answerText": "5", 'isCorrect': False },
        { "answerText": "6", 'isCorrect': False },
        { "answerText": "7", 'isCorrect': True },
        { "answerText": "8", 'isCorrect': False },
   ]
 }
]

Create API routes and HTTP request methods

In the Flask app.py, add your new API routes/endpoints after the LTI-related routes. We’re not currently using anything special, such as Flask-RESTful — but as our app grows, it might be advisable to use a nice REST extension and break the API out into it’s own module. Here’s our simple CRUD configuration for now.

###############################################################
# End LTI-related routes
###############################################################

###############################################################
# API routes
###############################################################

@app.route('/api/questions', methods=['GET'])
def api_get_questions():
    return jsonify(get_questions())

@app.route('/api/questions/<question_id>', methods=['GET'])
def api_get_question(question_id):
    return jsonify(get_question_by_id(question_id))

@app.route('/api/questions/add',  methods = ['POST'])
def api_add_question():
    questionText = request.get_json()
    return jsonify(insert_quiz_question(questionText))

@app.route('/api/questions/update',  methods = ['PUT'])
def api_update_question():
    questionText = request.get_json()
    return jsonify(update_question(questionText))

@app.route('/api/questions/delete/<question_id>',  methods = ['DELETE'])
def api_delete_question(question_id):
    return jsonify(delete_question(question_id))

###############################################################
# End API routes
###############################################################

The HTTP GET on /api/questions will get us what we need in our current quiz frontend. The other endpoints are available for use as your app grows. They are here to cover all the bases and scale the project on the back-end getting ahead of necessary features — one idea would be a new component that allows instructors to add new questions!

One last file that we’ll need to create alongside our app.py is a dbmethods.py. module. Place your methods (called by our API handlers) for performing database queries in this file:

import sqlite3

###############################################################
# Database functions
###############################################################

def connect_to_db():
    conn = sqlite3.connect('database.db')
    return conn

def drop_db_table():
    try:
        conn = connect_to_db()
        conn.execute('''DROP TABLE questions''')
        conn.commit()
        print("Questions table dropped successfully")
    except:
        print("Questions table drop failed")
    finally:
        conn.close()

def create_db_table():
    try:
        conn = connect_to_db()
        conn.execute('''
            CREATE TABLE questions (
                question_id INTEGER PRIMARY KEY,
                questionText TEXT NOT NULL,
                answerOptions TEXT NOT NULL
            );
        ''')
        conn.commit()
        print("Questions table created successfully")
    except:
        print("Questions table creation failed")
    finally:
        conn.close()


def insert_quiz_question(questionText):
    inserted_question = {}
    try:
        conn = connect_to_db()
        cur = conn.cursor()
        cur.execute("INSERT INTO questions (questionText, answerOptions) VALUES (?, ?)", (questionText['questionText'], repr(questionText['answerOptions'])) )
        conn.commit()
        inserted_question = get_question_by_id(cur.lastrowid)
    except:
        conn().rollback()

    finally:
        conn.close()

    return inserted_question


def get_questions():
    questions = []
    try:
        conn = connect_to_db()
        conn.row_factory = sqlite3.Row
        cur = conn.cursor()
        cur.execute("SELECT * FROM questions")
        rows = cur.fetchall()

        # convert row objects to dictionary
        for i in rows:
            questionText = {}
            questionText["question_id"] = i["question_id"]
            questionText["questionText"] = i["questionText"]
            questionText["answerOptions"] = eval(i["answerOptions"])
            questions.append(questionText)

    except:
        questions = []

    return questions


def get_question_by_id(question_id):
    questionText = {}
    try:
        conn = connect_to_db()
        conn.row_factory = sqlite3.Row
        cur = conn.cursor()
        cur.execute("SELECT * FROM questions WHERE question_id = ?", (question_id,))
        row = cur.fetchone()

        # convert row object to dictionary
        questionText["question_id"] = row["question_id"]
        questionText["questionText"] = row["questionText"]
        questionText["answerOptions"] = eval(row["answerOptions"])
    except:
        questionText = {}

    return questionText


def update_question(questionText):
    updated_question = {}
    try:
        conn = connect_to_db()
        cur = conn.cursor()
        cur.execute("UPDATE questions SET questionText = ?, answerOptions = ? WHERE question_id =?", (questionText["questionText"], repr(questionText["answerOptions"]), questionText["question_id"]))
        conn.commit()
        #return the questionText
        updated_question = get_question_by_id(questionText["question_id"])
    except:
        conn.rollback()
        updated_question = {}
    finally:
        conn.close()

    return updated_question


def delete_question(question_id):
    message = {}
    try:
        conn = connect_to_db()
        conn.execute("DELETE from questions WHERE question_id = ?", (question_id,))
        conn.commit()
        message["status"] = "questionText deleted successfully"
    except:
        conn.rollback()
        message["status"] = "Cannot delete questionText"
    finally:
        conn.close()

    return message

These methods are essentially aligned with our API endpoints when they are called. They are operating on a local sqlite database which will get created once a connection is made. In our Flask app – whenever we launch the new server, we will drop the questions table, recreate it, and load questions from the questions.py module.

SOOO, keep in mind — if you used a tool to create new questions; 1) DB Browser for SQLite OR 2) the Python REPL to connect to the database, running manual queries OR 3) Postman (or curl from the command line) to add new test questions calling the /api/questions/add endpoint that we just created — make sure that you’ve removed or commented out the logic that drops the table to load up our default questions or you’ll lose your work!

There are other approaches that could be taken here, such as using an ORM like SQLAlchemy for Flask. Again, since this is a basic, simple project, we’re just relying on the one questions table for now. I also chose to do a couple of funky things… In order to store an array, I’m finding that I can easily apply repr() when storing — and eval() when retrieving, in order to store and query the exact string and retain the complete syntax of the array. It just seems to make things easier when working with Javascript notation in the end.

Next steps, import the 2 new modules.

In step 1, there were 2 lines commented out at the top of the app.py file that can now be added. The result should look like this, with all our other imports:

from questions import questions
from dbmethods import drop_db_table, create_db_table, get_questions, get_question_by_id, insert_quiz_question, update_question, delete_question

After our API routes, place a few lines that will drop and create the database table (we’re assuming “questions” for all of these). Again for scalability, add the acceptance of arguments (for any table name perhaps) if you want to make the database methods more general:

###############################################################
# End API routes
###############################################################

drop_db_table()
create_db_table()

for i in questions:
    print(insert_quiz_question(i))

After dropping and recreating a fresh table (while we’re developing), the code loops through the imported questions array and inserts them. I’m having all this print to the console for now.

Make sure you are in your Flask project folder and you have activated your virtual environment. Run the Flask server, which should start up and make itself available on localhost and TCP port 5000:

python app.py

My console looks like this:

Run a quick test to ensure the questions JSON is being served up from the backend:

http://localhost:5000/api/questions

Here’s just a portion of what we should be seeing:

Update frontend (react.js component) to use the Flask API

Our last piece before testing the full stack tool with a provider is to make sure we have removed the hard-coded quiz questions from our App.js. We want our React app to load the questions into memory from the get_questions API endpoint. The code below shows the hard-coded JSON commented out and replaced with a new version of the useEffect() hook:

  useEffect(() => {
    fetch("/api/questions")
      .then((response) => response.json())
      .then((json) => setQuestions(json))
      .catch((error) => console.error(error));
  }, []);

  // //useEffect(() => {
  // //  setQuestions([
  // //    {
  // //      questionText: "Where is the Taj Mahal?",
  // //      answerOptions: [
  // //        { answerText: "Agra", isCorrect: true },
  //         { answerText: "New Delhi", isCorrect: false },
  //         { answerText: "Mumbai", isCorrect: false },
  //         { answerText: "Chennai", isCorrect: false },
  //       ],
  //     },
  //     {
  //       questionText: "Where is the Great Wall of China?",
  //       answerOptions: [
  //         { answerText: "China", isCorrect: true },
  //         { answerText: "Beijing", isCorrect: false },
  //         { answerText: "Shanghai", isCorrect: false },
  //         { answerText: "Tianjin", isCorrect: false },
  //       ],
  //     },
  //   ]);
  // }, []);

Attempting to run the React development build will fail because of the relative path looking to load questions from the backend API. You’ll need to build the frontend again (like the end of Part 4) and place the resulting build files in the static/js and static/css directories in the Flask project. At that point we should be all set to test. Shoot me a comment on the related post if you run into any issues or have questions.

In the next part, we’ll test things out. Feel free to reference the following for getting that started on your own:

Ad-hoc platforms for LTI tool integrations testing
https://pypi.org/project/PyLTI1p3/
https://github.com/dmitry-viskov/pylti1.3-flask-example

I hope to also address some or all of the following in future posts (this is mostly a reminder for me):

  • Leverage the LTI Advantage Deeplinking Message spec to give folks with certain roles (teacher, instructor, TA) the ability to create new quiz questions.
  • Implement the use of libraries and extensions that equate to a better, more scaleable app:
    • Maybe Axios HTTP in the React.js project, placing the use of the fetch API with some other method.
    • Consider Flask-RESTful
    • Consider GraphQL and noSQL
    • Consider an ORM that more easily supports databases (Postgres, MySQL) that aren’t SQLite.
    • Convert to a new tutorial using Django, DRF, and Vue.js as front-end.
  • Address the unrealistic scoring logic currently in the LTI-related route.
  • A Troubleshooting post that addresses:
    • Launching the app as an instructor or admin and why the app fails to post unless you’re a student.
    • Potential CORS-related pitfalls??
  • Easier environment for development and automation / tooling.
  • Adding questions with Postman
  • Add toast notifications on correct/incorrect answers
  • Implement on Moodle
  • Implement on Canvas

Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Part 4

In parts 4 and 5 of this series, we’ll continue to build out some major backend components of our Quiz app. In Part 3, we created a simple React.js component but it currently attempts to post to grades to an invalid URL. This is because our approach to the project is to build the react (js/css) static files, but first we will need to update our Flask template to take care of this.

Create the Flask template

Back in Part 1, we set up our Flask app to look for template and static files in ./frontend/templates and ./frontend/static, respectively.

In Part 2, we then created an LTI Launch route to do the following:

We’re expected to return a template. I chose index.html for the name, but feel free to use a naming convention that works for your project. We also pass a number of arguments to the rendered page. We’re currently only using the highlighted properties (below) in our project. The others are examples of keys that could come in handy if you want to display the user’s name or confirm a deep link launch. We’ll cover this in a separate, future post.

 'page_title': 'LTI 1.3 Flask-React Quiz',
 'is_deep_link_launch': message_launch.is_deep_link_launch(),
 'launch_data': message_launch.get_launch_data(),
 'launch_id': message_launch.get_launch_id(),
 'curr_user_name': message_launch_data.get('name', ''),

Back in the Flask backend of our project, my layout looks like this. Yours may vary.

You will want to create a frontend directory within your root project directory. While this isn’t necessary to structure it this way (creating static and template directories in your Flask project root is fine), I like to denote that the frontend is in fact a separate react project, not your typical Flask project layout.

In your frontend directory, create static and templates directories at the same branch in your directory tree.

Create your empty template file in the templates directory. In my case, index.html. Enter or paste the following html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>{{ page_title }}</title>
    <script>
      var launchId = "{{ launch_id }}";
    </script>
    <link
      href="{{ url_for('static', filename='css/<Put Your React Static CSS File Here>) }}"
      rel="stylesheet"
    />
  </head>
  <body>
    <div id="root"></div>
    <script
      type="text/javascript"
      src="{{ url_for('static', filename='js/<Put Your React Static JS File Here') }}"
      charset="utf-8"
    ></script>
  </body>
</html>

This simple template does the following:

  • Grabs page_title and launch_id from the kwargs passed to the template when rendered.
  • References the static react CSS/JS files that we have yet to create.
  • The body of our Flask template loads a div with “root”. Always make sure the order is following this convention, at the top of the body of your HTML template / document, prior to loading the javascript <script>.

Build and Implement the React.js frontend

Next, you’ll want to build your React project (as you would for production) using the proper command at your terminal, the root of your frontend/react project, wherever you chose to create that. In my case, I’m using a separate project altogether. Another approach would’ve been to build everything in the frontend directory within your backend/Flask project. I will cover a more “elegant” (questionable) method by using webpack, in a later post. I’m also open to suggestions for better tooling of this method. I aspire to be a solid Vue.js and Django developer, so some of this is outside of my … area of concentration, perhaps? Anywho:

npm run build

Finally copy your react project’s static directory (including all sub-directories) over to the Flask project, so that the structure is similar to this:

React.js project: /build/static.. to Flask project: /frontend/static

The file names that are generated are unique to your project, so they’ll be different from mine in the image above.

Finally, update the index.html template with names that match your main.xxxxx.js and main.xxxxx.css files. I’ve highlighted the 2 lines of code that need to be changed in the original screenshot of the template above.

In Part 5, we’ll make one last update to our React component, so that we’re dynamically retrieving data via API and a database. But first, we’ll create a default questions python module, API routes and method handlers, and some backend database handlers.

Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Part 2

Check out part one first — where we walk through the setup of the Flask backend: Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Part 1

Part 2 – Flask and implementing LTI-related routes

Picking up where we left off in the app.py file in our project root directory

###############################################################
# LTI-related routes
###############################################################

@app.route('/login/', methods=['GET', 'POST'])
def login():
    tool_conf = ToolConfJsonFile(get_lti_config_path())
    launch_data_storage = get_launch_data_storage()
    flask_request = FlaskRequest()

    target_link_uri = flask_request.get_param('target_link_uri')
    if not target_link_uri:
        raise Exception('Missing "target_link_uri" param')

    oidc_login = FlaskOIDCLogin(
        flask_request,
        tool_conf,
        launch_data_storage=launch_data_storage
    )

    return oidc_login\
        .enable_check_cookies()\
        .redirect(target_link_uri)

The above function contains some elements that we will see repeat in the following – such as the tool conf, FlaskRequest, and launch data storage. The login route is used to invoke an LTI 1.3 launch where you first receive a login initialization request and return to the platform. The request is handled by first creating a new OIDCLogin object. An exception is raised if an OIDC registration or redirect url doesn’t exist.

  • Line 76 – ToolConfJsonFile() – receives location of the json config file defined in Step 1.
  • Line 77 – get_launch_data_storage() – retrieves Flask Cache storage.
  • Line 78 – Set up the session through a Flask request.
@app.route('/launch/', methods=['POST'])
def launch():
    tool_conf = ToolConfJsonFile(get_lti_config_path())
    request = FlaskRequest()
    launch_data_storage = get_launch_data_storage()
    message_launch = FlaskMessageLaunch(
        request,
        tool_conf,
        launch_data_storage=launch_data_storage
    )
    message_launch_data = message_launch.get_launch_data()
    #pprint.pprint(message_launch_data)

    tpl_kwargs = {
        'page_title': 'LTI 1.3 Flask App',
        'is_deep_link_launch': message_launch.is_deep_link_launch(),
        'launch_data': message_launch.get_launch_data(),
        'launch_id': message_launch.get_launch_id(),
        'curr_user_name': message_launch_data.get('name', ''),
    }
    return render_template('index.html', **tpl_kwargs)

The launch route above is similar in set up as the login() function and performs an LTI message launch, following successful authorization.

The tpl_kwargs dictionary includes arguments that will be sent to the browser when rendering the Flask template page. Some of these aren’t necessarily used for this tutorial and will be ignored when launching the react quiz app. The unused keys in this project include deep link verification and passing in the current user name.

@app.route('/api/score/<launch_id>/<earned_score>/', methods=['POST'])
def score(launch_id, earned_score):
    tool_conf = ToolConfJsonFile(get_lti_config_path())
    flask_request = FlaskRequest()
    launch_data_storage = get_launch_data_storage()
    message_launch = FlaskMessageLaunch.from_cache(launch_id, flask_request, tool_conf,
                                                           launch_data_storage=launch_data_storage)

    resource_link_id = message_launch.get_launch_data() \
        .get('https://purl.imsglobal.org/spec/lti/claim/resource_link', {}).get('id')

    if not message_launch.has_ags():
        raise Forbidden("Don't have grades!")

    sub = message_launch.get_launch_data().get('sub')
    timestamp = datetime.datetime.utcnow().isoformat() + 'Z'
    earned_score = int(earned_score)

    grades = message_launch.get_ags()
    sc = Grade()
    sc.set_score_given(earned_score) \
        .set_score_maximum(100) \
        .set_timestamp(timestamp) \
        .set_activity_progress('Completed') \
        .set_grading_progress('FullyGraded') \
        .set_user_id(sub)

    sc_line_item = LineItem()
    sc_line_item.set_tag('score') \
        .set_score_maximum(100) \
        .set_label('Score')
    if resource_link_id:
        sc_line_item.set_resource_id(resource_link_id)

    result = grades.put_grade(sc, sc_line_item)

    return jsonify({'success': True, 'result': result.get('body')})

The above will POST the final grade to the platform (LMS) through the LTI Assignment and Grade Service (AGS). We’ll accomplish the following, assuming the user has the proper role and the assignment has been set up in the platform to accept the grade update:

  • A check is performed for AGS availability and will send any errors back to the server log.
  • The score passed to the API endpoint is pushed to the grade center (or gradebook) after setting up the column with the proper configuration and timestamp.

This final route is set up for exposing the public key for our tool:

@app.route('/jwks/', methods=['GET'])
def get_jwks():
    tool_conf = ToolConfJsonFile(get_lti_config_path())
    return jsonify({'keys': tool_conf.get_jwks()})

###############################################################
# End LTI-related routes
###############################################################

In part 3, we will write our (very simple) Quiz front-end using React.js and Tailwind CSS.

Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Part 1

Let’s begin again! Welcome to the real part 1 of this series.

Part 1 – Initial Setup of the Flask Backend

Goals:

  • Build a Python Flask-based backend for a simple quiz application using the pyLTI1p3 library that supports LTI 1.3 / LTI Advantage. Score passback is included in the launch.
  • Build a simple react.js front-end that reads quiz questions from APIs provided by Flask. The front-end will load all questions up-front in our example.
  • The Flask backend of the final quiz tool will handle the following tasks:
    • LTI conversations with the platform (LMS), leveraging a set of decorators and functions.
    • Store and retrieve newly added quiz questions in a sqlite 3 database.
    • Provide a set of APIs for adding and retrieving quiz questions and answers.
  • The react.js front-end expects React 18 and in this initial exercise, we will be building react into static files in our development environment. While this is not generally the most efficient or best approach, we are building a small application where this is acceptable. For larger projects, alternate approaches using Webpack or a separate development nginx server would be useful.
  • I have also added Tailwind to this project, even though some vanilla CSS would probably do the trick. Why not!

Assumptions:

  • You have a working knowledge of Python 3 and React.js 18.
  • While I will do my best to walk through every line of code, even I do not have a full understanding of the libraries in use. Where I don’t have a ton of information, I will do my best provide links to sites with the most up-to-date stuff.
  • You are working on a Linux / MacOS – based system. Most of the commands are transferable if you are using GitBash on Windows… or a Linux subsystem.
  • Much of this code was inspired by Dmitry Viskov’s Flask Example repository. The code implements a fun breakout game written in plain JS that pushes a score and time back to the LMS. It also supports some deep linking for setting a difficulty. I may introduce deep linking at a later time (possibly for giving a teacher the ability to create new quiz questions) but that particular LTI service is not part of this tutorial.

Alright, let’s get started!

Make sure you are in an empty project folder, name of your choosing. Here are the commands you’ll want to use for getting your environment set up:

python3 -m venv quizenv
source quizenv/bin/activate

Add the dependencies to your project. Create a requirements.txt file in your project directory and add the following text. We won’t be using all of these in the first couple of parts but will eventually need them:

antiorm==1.2.1
blinker==1.7.0
cachelib==0.9.0
certifi==2023.7.22
cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
cryptography==41.0.5
db==0.1.1
db-sqlite3==0.0.1
Deprecated==1.2.14
Flask==3.0.0
Flask-Caching==2.1.0
Flask-Cors==4.0.0
idna==3.4
itsdangerous==2.1.2
Jinja2==3.1.2
jwcrypto==1.5.0
MarkupSafe==2.1.3
pycparser==2.21
PyJWT==2.8.0
PyLTI1p3==2.0.0
requests==2.31.0
typing_extensions==4.8.0
urllib3==2.0.7
Werkzeug==3.0.1
wrapt==1.16.0

In the same directory, load up the required dependencies

pip install -r requirements.txt

In your project directory, create an app.py by using the ‘touch’ command or creating the file directly in your code editor. My preferred is VSCode. Place the following code at the top of the new app.py file:

import os, pprint, datetime
from tempfile import mkdtemp

# from questions import questions
# from dbmethods import drop_db_table, create_db_table, get_questions, get_question_by_id, insert_quiz_question, update_question, delete_question

from flask import Flask, request, jsonify, render_template, url_for
from flask_cors import CORS
from flask_caching import Cache

from werkzeug.exceptions import Forbidden
from werkzeug.utils import redirect
from pylti1p3.contrib.flask import FlaskOIDCLogin, FlaskMessageLaunch, FlaskRequest, FlaskCacheDataStorage
from pylti1p3.deep_link_resource import DeepLinkResource
from pylti1p3.grade import Grade
from pylti1p3.lineitem import LineItem
from pylti1p3.tool_config import ToolConfJsonFile
from pylti1p3.registration import Registration

class ReverseProxied:
    def __init__(self, app):
        self.app = app

    def __call__(self, environ, start_response):
        scheme = environ.get('HTTP_X_FORWARDED_PROTO')
        if scheme:
            environ['wsgi.url_scheme'] = scheme
        return self.app(environ, start_response)

app = Flask(__name__,
            static_url_path='', 
            static_folder='./frontend/static',
            template_folder='./frontend/templates')

CORS(app, resources={r"/*": {"origins": "*"}})

app.wsgi_app = ReverseProxied(app.wsgi_app)

config = {
    "DEBUG": True,
    "ENV": "development",
    "CACHE_TYPE": "simple",
    "CACHE_DEFAULT_TIMEOUT": 600,
    "SECRET_KEY": "3dj90jdwi0d320edj9d",
    "SESSION_TYPE": "filesystem",
    "SESSION_FILE_DIR": mkdtemp(),
    "SESSION_COOKIE_NAME": "pylti1p3-flask-app-sessionid",
    "SESSION_COOKIE_HTTPONLY": True,
    "SESSION_COOKIE_SECURE": False,   # should be True in case of HTTPS usage (production)
    "SESSION_COOKIE_SAMESITE": None,  # should be 'None' in case of HTTPS usage (production)
    "DEBUG_TB_INTERCEPT_REDIRECTS": False
}
app.config.from_mapping(config)
cache = Cache(app)
  • Line 1 – importing some common Python modules. pprint is mostly used for debug.
  • Line 2 – tempfile is used for a temporary storage location. Specifically, a call to mkdtemp will be used to define a session file location. More info.
  • Lines 4-5 – commenting these out for now. We’ll build these custom dependencies / modules out later.
  • Line 7 – Import common Flask libraries for working with APIs and rendering pages.
  • Line 8 – CORS support for working with external domains / separate ports. This may not be necessary for our current approach, since we’re only running a single server in development, with React builds… but I like to make sure all bases are covered for all scenarios.
  • Line 9 – import the caching libraries needed for our backend. This will get configured later when our app is initialized.
  • Lines 11-12 – WSGI utilities for handling exceptions and redirects.
  • Lines 13-18 – PyLTI1p3 imports. More information can be found here: https://pypi.org/project/PyLTI1p3/
  • Lines 20-28 – A class that’s designed as a safeguard against URL munging when a web client is serving up static files (JS / CSS), when Flask generates HTML content.
  • Lines 30-33 – Create an instance of the imported Flask class.
  • Line 35 – CORS is used here for specifying origins that are allowed to access our future API endpoints.
  • Line 37 – Applying our reverse proxy from Line 20 to our wsgi application.
  • Ines 39 – 54 – Configure Flask for session and cache handling. You’ll need to generate your own “SECRET_KEY”. I just mashed some keys on this one. In production, this is used to keep client/server sessions secure. Feel free to paste in the output from something like this — run it in the REPL:
>>> import os
>>> os.urandom(24)

Now, let’s define some Python functions in app.py, used to further configure our LTI application:

def get_lti_config_path():
    return os.path.join(app.root_path, 'configs', 'reactquiz.json')

def get_launch_data_storage():
    return FlaskCacheDataStorage(cache)

def get_jwk_from_public_key(key_name):
    key_path = os.path.join(app.root_path, '..', 'configs', key_name)
    f = open(key_path, 'r')
    key_content = f.read()
    jwk = Registration.get_jwk(key_content)
    f.close()
    return jwk
  • lines 59-60: A method for accessing the Flask cache.
  • lines 62-68: A method that can be used for serving up the public jwks key for our tool.

At the bottom of your app.py, add the following lines of code (with some additional debug comments in case they are needed for future troubleshooting):

if __name__ == "__main__":
    #app.debug = True
    #app.run(debug=True)
    app.run()

As far as the rest of the initial configuration – make sure other config files that are necessary (at least a public and private key) are available in the <application root>/configs directory (see my screenshot below). More information on these files can be found in my other posts and in the pyLTI1p3 documentation:
LTI Moodle Integration using PyLTI 1.3
Adhoc Platforms for LTI Tool Integrations Testing
pyLTI1p3 – LTI 1.3 Advantage Tool

In part 2, we will finish up the app.py by adding some LTI-related routes that will eventually be fetched by our React Quiz front-end.

Revised on 5/25/24

Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3

Update: 11/20/2023: Please use the newer PART 1 of this series here:
Build a Simple LMS-Integrated Quiz App Using Python, React.js, and LTI 1.3 – Part 1
The code below references an older Python code that’s not a part of pyLTI1p3.

Update: 5/3/2023: In the process of creating part 2 of this series, I discovered a lot in my research (like, maybe this isn’t the right path for a simple solution) and will be expanding by finding a more viable and maintainable option. Several folks have reached out to me about this article, only to discover that they could use a simpler approach. I began asking questions like: 1) Why use React.js when I could use Flask’s templating engine? Perhaps because React is more common than something like Jinja? 2) Why not use a framework that is built for this purpose, such as LTIjs? More to share on this topic at a future date as I continue to explore the latest frameworks like Vue.js 3 and using Django instead of Flask from the outset. I love it that this is already developing into a tiny community, helping to give this site some direction… and if you feel strongly about the direction I’m taking with this post / series, please click “leave a comment” in the title sidebar.


The project idea is all in the title… Let’s plan out what is involved and what the result might look like.

This is just a high-level overview of a Quiz application built with Python Flask and React.js Javascript library. We’ll have multiple choice as our only option — again just keeping the development effort somewhat simple. The actual implementation will depend on the requirements below and the LMS that we are integrating with BUT I want to make this as agnostic as possible, where the LMS really shouldn’t matter as long as the platform we’re integrating with is LTI1.3/Advantage certified.

Dall-E generated: a surrealist painting of a cyberpunk, sitting at a desk in a desert at night

1. Flask Backend

  • Create a Flask application that implements LTI 1.3 by using an LTI library. I think we have a good idea of what that will be: PyLTI1p3.
  • Create a RESTful API for the quiz application that allows React frontend to retrieve questions and submit answers.
  • The backend should also be able to store the scores of the students and their grading status. Perhaps we’ll just use SQLite, although I believe this isn’t the best choice. The intention here will be to migrate to PostgreSQL but SQLite is good enough for establishing the initial database schema.

2. React Front-end

  • Create a React application that fetches questions from the Flask backend and displays them to the user.
  • Allow users to submit answers to the questions and provide feedback on their responses.
  • The frontend should also be able to retrieve the scores and grading status of the students.
  • If this were Django, and since it’s not a complex build, I would likely skip this part and implement the front-end part of the stack through it’s native templating engine. My favorite options are Vue.js and React.js because of their flexibility and they are fine to use with Django.

3. Integration with the LMS

  • Implement the LTI 1.3 protocol in the Flask backend to communicate with the LMS.
  • Pass the scores of the students back to the LMS and create a gradebook column in the LMS.
  • Post the scores along with grading status to the LMS.

Preview of Step 2 (which I will post soon) — Using PyLTI1p3 with Flask might look something like this. Let’s explore further in Part 2, where we’ll refine this and test it out.

from flask import Flask, request
from pylti1p3.flask import lti

app = Flask(__name__)

@app.route('/api/questions', methods=['GET'])
@lti(request='launch', role='instructor')
def get_questions():
    # Retrieve questions from the database
    # ...

    return json.dumps(questions)

@app.route('/api/scores', methods=['POST'])
@lti(request='launch', role='student')
def submit_score():
    score = request.form['score']
    # Store the score in the database
    # ...

    # Pass the score back to the LMS and create a gradebook column
    request.lti_outcome_service().post_replace_result(score)
    return 'Score submitted successfully'

if __name__ == '__main__':
    app.run()

This code uses the @lti decorator from pylti1p3 to implement the LTI 1.3 protocol and check if the request is valid and if the user has the correct role.

The get_questions function retrieves the questions from the database and returns them to the frontend.

The submit_score function stores the score in the database and passes it back to the LMS using the post_replace_result method from the LTI outcome service.

This is just sample code and we will need to make many modifications in order to fit the specific requirements of our project.

Thank you and have a great day!

LTI Moodle Integration using pyLTI 1.3 (Python/Flask Game Example)

Dall-E generated: “sunny surrealist painting of a video game integrated into a python’s brain”

Definitions and References for this post

  • IMS Global / 1EdTech – An excellent organization that continues to put forth great standards and frameworks for EdTech integrations.
  • LTI = Learning Tools Interoperability: A set of standards and services, primarily used for the proper integration of platforms (Learning Management Systems) and third-party tool providers, typically servicing Higher Ed and K12.
  • dmitry-viskov’s pylti1.3 Github repo – a Python implementation of the 1EdTech PHP tool. We’re going to use the example game provided by the developer.
  • URLs used for the local tool configuration. Note that you can configure these how you want, so they could vary. The default URLs are:
    • OIDC Login URL: http://127.0.0.1:9001/login/
    • LTI Launch URL: http://127.0.0.1:9001/launch/
    • JWKS URL: http://127.0.0.1:9001/jwks/ (if you choose to implement a public keyset URL).
  • Moodle LTI 1.3 Support page: https://docs.moodle.org/dev/LTI_1.3_support
  • Moodle 4 Doc for Publishing an LTI Tool and an excellent primer for a better understanding of LTI!

This post builds off of my previous post where I used the saLTIre platform emulator to test out the example breakout game using the pyLTI1.3 Flask test implementation. The game does some score pass-back and all seems to work well with Moodle. I’ll walk through all the steps!

My Test Environment

When I was working for Kaltura as a Solutions Engineer, I set up this Moodle 4 instance for demos. This particular subdomain falls within a shared hosting environment in my reseller Hostgator account, so there were some initial issues with being able to write back to Moodle via a POST method (from the locally hosted Flask LTI Game). Their support was able to make some Apache Mod Security exceptions for me without screwing with needed security… and we got things going. For those having to do their own troubleshooting in this matter — and if you’re using a cheap-o shared / test environment like me, here’s a snippet from the chat transcript and what Hostgator support had to do for me:

[*] URLS Affected: /mod/lti/services.php/4/lineitems
[*] Description: Atomicorp.com WAF Rules: Request content type is not allowed by policy
I am trying to check and whitelist the Mod Security from my end
[*] Whitelisting rule 391213 for moodle.videotesting.org
[*] Testing apache configuration…
[*] Configuration good! Restarting Apache…DONE!

More about tuning Mod_Security for your needs can be found here.

Step 1 – Local App Configuration

As far as the local configuration, I updated the existing Moodle configuration example in /configs/game.json with my Moodle parameters:

  • You’ll find the needed URLs in your Moodle integration setup (in the next step).
  • The client_id and deployment_ids parameters needed here are generated by Moodle once the configuration is saved.
  • As you can see, I’m still using the same private.key file from my previous post about implementing with saLTIre. You’ll copy the contents of that file for use in the next step.

Step 2 – Moodle 4 configuration

  • I implemented the LTI globally (not at the course level): Site Administration -> Plug-ins -> Activity Modules ->External Tool -> Manage Tools -> Configure a Tool Manually
  • Tool URL: We’re using the localhost address and port configured in the Python Flask game.
  • LTI Version: LTI 1.3
  • Client ID: Retrieved once saved.
  • Public key type: RSA key. If you want to give the keyset URL option a try, you’ll use the JWKS URL from the resource section and the top of my post.
  • Public Key: Paste in from the public.key file in Step 1
  • Login URL and Redirection URI: Use the localhost’s configuration.
  • Services and Privacy settings can be configured based on institutional preferences but we want to make sure that grade sync / column management and student info passback is enabled if you want to see the student name and get the full exchange of information with the LTI game.
  • Deep Linking options should be enabled.
  • Under Miscellaneous, I made sure that my tool appeared in all courses for my entire site, selecting Site Hostname.
Click to Enlarge
Click to enlarge
Click to Enlarge

Once the changes are saved, you can grab what you need by going back to Site Administration -> Plug-ins -> Activity Modules ->External Tool -> Manage Tools and selecting the “View Configuration Details” button on the card for the Flask Game tool that was just created. From there you can grab the Client ID, URLs, and Deployment ID info needed by the too configuration in Step 1.

After all has been saved. Let’s test with a student in a course!

As the instructor, if I have a look at the gradebook in my course, I can see Morty’s scores. Granted, I probably need to change the range based on the difficulty of the game, etc.