Backend

Routes

Front page view ‘/’ (index.html)

The route for our homepage is self-explanatory, taking the user to index.html:
@app.route('/')
def home():
    return render_template("index.html")

Quiz summaries view ‘/feedback’ (feedback.html)

Our feedback route loads the quiz summaries for a given user id (hardcoded to 1) and loads them into the feedback.html page.
@app.route("/feedback", methods=['GET'])
def feedback():
    user_id = 1
    quizzes = DB_query_user_quiz_summaries(user_id)
    return render_template("feedback.html", quizzes=quizzes)

Quiz feedback detail view ‘/quiz_feedback’ (quiz_feedback.html)

The quiz feedback view tries to gather detailed feedback for a specified quiz id.
We start by fetching feedback ID as provided in the request:
@app.route('/quiz_feedback')
def quiz_feedback():
    user_id = 1
    feed_id = request.args.get('feed_id', type=int)
If there is an invalid (or no) feedback id, we’ll feed an error message to the page:
if feed_id is None:
    return render_template(
        "quiz_feedback.html",
        summary=None,
        questions=[],
        error="Select a completed quiz from your profile first.",
    )
We pull the feedback using DB_query_quiz_feedback_detail
questions = DB_query_quiz_feedback_detail(user_id, feed_id)
If there exists no feedback for the given ID (the feedback object is empty or doesn’t exist), we also give an error message:
if not questions:
    return render_template(
        "quiz_feedback.html",
        summary=None,
        questions=[],
        error="No feedback found for that quiz attempt.",
    )
Then we pull all the feedback data and convert it into the formats we want, then pack the data nice and neat:
total_questions = len(questions)
correct_count = sum(1 for row in questions if row['is_correct'])
topic_names = sorted(set(row['topic'] for row in questions))
topic = topic_names[0] if len(topic_names) == 1 else f"Mixed: {' & '.join(topic_names)}"

summary = {
    'feed_id': feed_id,
    'topic': topic,
    'total_questions': total_questions,
    'correct_count': correct_count,
    'score_percent': round((correct_count / total_questions) * 100),
}
Finally, send our quiz summary off to the quiz feedback template for display:
return render_template("quiz_feedback.html", summary=summary, questions=questions, error=None)

Login form view ‘/login’ (login.html)

This route loads our (very placeholder) login template.
@app.route('/login', methods=['GET'])
def login():
    return render_template("login.html")

Quiz form view ‘/quiz’ (quiz.html + start_quiz.html)

Our quiz route tries to gather a random selection of questions from a specified topic up to a specified limit.
The topic and limit are expected to come through with the page request.
@app.route('/quiz')
def quiz():
    topic = request.args.get('topic')
    limit = request.args.get('limit')
First, we check if the topic actually has something there, redirecting to the start quiz form if not;
if not topic or topic.strip() == "" or topic.isdigit():
    return redirect(url_for('app.start_quiz'))
We check if the provided question limit is a number between 3 and 20, again redirecting if anything is awry;
try:
  limit = int(limit)
except (ValueError, TypeError):
  return redirect(url_for('app.start_quiz'))

if limit < 3 or limit > 20:
  return redirect(url_for('app.start_quiz'))
We show topic selection instead if there’s no selected topic;
if not topic:
    return render_template("start_quiz.html")
We load our questions from the database using DB_query_questions_list, or grab an empty list if there’s no questions;
db_questions = DB_query_questions_list([topic], limit)
if not isinstance(db_questions, list):
    db_questions = []
We turn our database questions into the JSON our template expects using transform_db_question, clean out any empty questions, and then pass it through to the quiz template.
selected_questions = [transform_db_question(row) for row in db_questions]
selected_questions = [question for question in selected_questions if question is not None]
return render_template("quiz.html", questions=selected_questions)

Quiz topic selection view ‘/start_quiz’ (start_quiz.html)

This route loads the quiz topic/limit selection form.
Users will select a topic and question limit (3-20) for their quiz.
@app.route('/start_quiz')
def start_quiz():
    return render_template("start_quiz.html")

Individual question ‘/question/<int:question_id>’ (quiz.html)

This quiz loads the quiz form but with a single question.
If we can’t find the question in the database, we pass an empty list (the form itself has an error message it displays).
Mostly exists for debugging, but nice to have.
@app.route('/question/<int:question_id>')
def question(question_id):
db_question = DB_query_question_by_id(question_id)
if db_question is None:
    return render_template(
    "quiz.html",
    questions=[],
    )

return render_template(
    "quiz.html",
    questions=[transform_db_question(db_question)],
)

API

Submit quiz ‘/api/submitQuiz’

Our submit quiz API method tries to save a completed quiz to the database.
We expect the Quiz form to call this when a quiz is finished.
Our first point of order is to check we have answers:
@app.route('/api/submitQuiz', methods=['POST'])
def submit_quiz():
    # Read and validate the JSON payload from the request body
    data = request.get_json(silent=True)
    if not data or 'answers' not in data or not isinstance(data['answers'], list):
        return jsonify({'error': 'Missing or invalid answers payload'}), 400

    answers = data['answers']
    if not answers:
        return jsonify({'error': 'No answers submitted'}), 400
We set our placeholder user id as 1, but perform validation to ensure it’s correct anyway.
Feedback in the database is associated with a user, so we need to provide this.
user_id = data.get('user_id', 1)  # Temporary until login/session is connected.
try:
    user_id = int(user_id)
except (ValueError, TypeError):
    return jsonify({'error': 'Invalid user id'}), 400

if user_id <= 0:
    return jsonify({'error': 'Invalid user id'}), 400

if not _user_exists(user_id):
    return jsonify({'error': 'User does not exist'}), 400
Then we iterate over our answers, tearing out the data into separate lists.
We wrap the whole thing in a try, and catch any malformed data to throw errors.
try:
    que_id_list = []
    ans_corr_list = []
    user_ans_ind_list = []

    for answer in answers:
        q_id = answer.get('qID') if isinstance(answer, dict) else None
        selected = answer.get('selected') if isinstance(answer, dict) else None
        is_correct = answer.get('isCorrect') if isinstance(answer, dict) else None
We validate that the question data exists for every answer object.
This means checking every question id, selected answer index, and correct/incorrect boolean.
    if q_id is None or selected is None or is_correct is None:
        return jsonify({'error': 'Invalid answer object'}), 400

    try:
      q_id = int(q_id)
      selected = int(selected)
    except (ValueError, TypeError):
      return jsonify({'error': 'Invalid answer object'}), 400

    if q_id <= 0:
      return jsonify({'error': 'Invalid question id'}), 400

    if selected < 0 or selected > 3:
      return jsonify({'error': 'Answer index out of range'}), 400

    if isinstance(is_correct, bool):
      is_correct = bool(is_correct)
    elif is_correct in (0, 1):
      is_correct = bool(is_correct)
    else:
      return jsonify({'error': 'Invalid answer correctness value'}), 400

    que_id_list.append(q_id)
    user_ans_ind_list.append(selected)
    ans_corr_list.append(is_correct)

if not _question_ids_exist(que_id_list):
  return jsonify({'error': 'Unknown question id'}), 400
Once we’ve pulled the whole thing into lists, we make the query to add the feedback to database.
We have some backup exceptions just in case something else in the conversion process went wrong.
    DB_query_insert_feedback(
        user_id=user_id,
        que_id_list=que_id_list,
        ans_corr_list=ans_corr_list,
        user_ans_ind_list=user_ans_ind_list,
    )
except (ValueError, TypeError):
    return jsonify({'error': 'Answer values must be valid numbers/booleans'}), 400
except Exception as exc:
    return jsonify({'error': f'Failed to save quiz feedback: {exc}'}), 500
We then confirm it didn’t go horribly wrong:
return jsonify({
    'status': 'saved',
    'answerCount': len(answers),
}), 200

Health check ‘api/health’

Simple health check - if this still works we know we haven’t nuked the entire app!
@app.route('/api/health')
def health():
    return jsonify({'status': 'ok', 'message': 'Flask app is running'})

Query helpers

Database path resolvers

We have a couple of private methods for finding the database file:

_default_db_path

Tries to find database in default location by first navigating to root, then root/edulearn.db.
def _default_db_path():
    script_dir = Path(__file__).resolve().parent
    return str(script_dir / "edulearn.db")

_resolve_db_path

Checks if there’s a config DATABASE_PATH, otherwise passes the default.
Custom DATABASE_PATH is used in testing configuration.
def _resolve_db_path():
try:
    from flask import current_app

    db_path = current_app.config.get("DATABASE_PATH")
    if db_path:
        return db_path
except RuntimeError:
    pass

return _default_db_path()

connector

Sets up a database connection with the given sqlite database file, and returns it.
def connector():
    return sqlite3.connect(_resolve_db_path())

get_cursor

Returns a database connection along with a cursor, for DB methods to use.
Enforces existing foreign keys for any data manipulation via sqlite PRAGMA command.
def get_cursor():
    connection = connector()
    connection.execute("PRAGMA foreign_keys = ON")
    connection.row_factory = sqlite3.Row
    return connection, connection.cursor()

Question queries

DB_query_questions_list

This function pulls a list of random questions from the provided topic(s).
We only gather up to the specified limit of questions.
‘placeholders’ is used to feed the topic(s) into the query - there’ll be a ‘?’ for each topic, which is then replaced with the passed topic names when the query is made.
We only append limit to the query if a limit is provided.
Our list of questions is returned as a list of dictionaries, which then get transformed and fed to the quiz template.
def DB_query_questions_list(topic_list, limit=None): # takes in a list of topics
    if not topic_list:
        return []

    if limit is not None and isinstance(limit, (int)) and limit < 0:
        return []

    connection, cursor = get_cursor()

    placeholders = ",".join("?" for _ in topic_list)

    query = f"""
        SELECT q.QUE_ID,
            q.QUE_question,
            q.QUE_ans_1,
            q.QUE_ans_2,
            q.QUE_ans_3,
            q.QUE_ans_4,
            q.QUE_ans_correct,
            t.TOP_name
        FROM QUESTIONS q
        JOIN TOPICS t ON q.TOP_ID = t.TOP_ID
        WHERE t.TOP_name IN ({placeholders})
        ORDER BY RANDOM()
    """

    if limit is not None:
        query += " LIMIT ?"
        cursor.execute(query, (*topic_list, limit))
    else:
        cursor.execute(query, topic_list)

    rows = cursor.fetchall()
    connection.close()

    return [dict(row) for row in rows]

DB_query_question_by_id

Gather question data for a specified question ID.
Returns a single dictionary.
This is used only in the individual question ID route.
def DB_query_question_by_id(que_id):
    connection, cursor = get_cursor()

    query = """
        SELECT q.QUE_ID,
            q.QUE_question,
            q.QUE_ans_1,
            q.QUE_ans_2,
            q.QUE_ans_3,
            q.QUE_ans_4,
            q.QUE_ans_correct,
            t.TOP_name
        FROM QUESTIONS q
        JOIN TOPICS t ON q.TOP_ID = t.TOP_ID
        WHERE q.QUE_ID = ?
    """

    cursor.execute(query, (que_id,))
    row = cursor.fetchone()
    connection.close()

    return dict(row) if row else None

Feedback queries

DB_query_insert_feedback

Save a completed quiz attempt to the feedback table.
We increment feedback ID and create a new feedback entry from our provided quiz response data.
def DB_query_insert_feedback(user_id,que_id_list,ans_corr_list,user_ans_ind_list):
    connection = connector()
    connection.row_factory = sqlite3.Row
    cursor = connection.cursor()

    cursor.execute("SELECT COALESCE(MAX(FEED_ID), 0) + 1 FROM FEEDBACK")
    feed_id = cursor.fetchone()[0]

    for i in range(len(que_id_list)):
        connection.execute("""
                INSERT INTO FEEDBACK (FEED_ID, ACC_ID, QUE_ID, FEED_answer_status, FEED_user_answer_index)
                VALUES (?, ?, ?, ?, ?)
            """, (feed_id, user_id, que_id_list[i], int(ans_corr_list[i]), user_ans_ind_list[i]))

    connection.commit()
    connection.close()

DB_query_user_quiz_summaries

Grabs all quiz summaries for a given user ID.
Returned as a list of dictionaries.
We pull together and calculate a few data items in the query:
  • topic name is ‘Mixed’ if multiple question topics are present

  • total_questions is the number of questions

  • correct_count tallies correct answers

  • score_percent calculates percentage of correct answers

def DB_query_user_quiz_summaries(user_id):
    connection = connector()
    connection.row_factory = sqlite3.Row
    cursor = connection.cursor()

    cursor.execute(
        """
        SELECT
            f.FEED_ID AS feed_id,
            CASE
                WHEN COUNT(DISTINCT t.TOP_name) = 1 THEN MIN(t.TOP_name)
                ELSE 'Mixed'
            END AS topic,
            COUNT(*) AS total_questions,
            SUM(CASE WHEN f.FEED_answer_status = 1 THEN 1 ELSE 0 END) AS correct_count,
            ROUND(
                100.0 * SUM(CASE WHEN f.FEED_answer_status = 1 THEN 1 ELSE 0 END) / COUNT(*),
                0
            ) AS score_percent
        FROM FEEDBACK f
        JOIN QUESTIONS q ON q.QUE_ID = f.QUE_ID
        JOIN TOPICS t ON t.TOP_ID = q.TOP_ID
        WHERE f.ACC_ID = ?
        GROUP BY f.FEED_ID
        ORDER BY f.FEED_ID DESC
        """,
        (user_id,)
    )

    rows = cursor.fetchall()
    connection.close()
    return [dict(row) for row in rows]

DB_query_quiz_feedback_detail

Fetches feedback details for a given feedback and user id.
Returns all feedback question and answer data in a list of dictionaries, one dict per feedback item.
Ordering of query by row id is done so that the feedback display shows questions in order by default.
def DB_query_quiz_feedback_detail(user_id, feed_id):
connection = connector()
connection.row_factory = sqlite3.Row
cursor = connection.cursor()

cursor.execute(
    """
    SELECT
        f.FEED_ID AS feed_id,
        t.TOP_name AS topic,
        q.QUE_ID AS question_id,
        q.QUE_question AS question_text,
        q.QUE_ans_1 AS option_1,
        q.QUE_ans_2 AS option_2,
        q.QUE_ans_3 AS option_3,
        q.QUE_ans_4 AS option_4,
        q.QUE_ans_correct AS correct_answer_index,
        f.FEED_user_answer_index AS user_answer_index,
        f.FEED_answer_status AS is_correct
    FROM FEEDBACK f
    JOIN QUESTIONS q ON q.QUE_ID = f.QUE_ID
    JOIN TOPICS t ON t.TOP_ID = q.TOP_ID
    WHERE f.ACC_ID = ? AND f.FEED_ID = ?
    ORDER BY f.rowid
    """,
    (user_id, feed_id)
)

rows = cursor.fetchall()
connection.close()
return [dict(row) for row in rows]

Misc

create_app

We build the app with dummy secret key.
The default database path is fetched and added to config.
def create_app():
flask_app = Flask(__name__)
flask_app.config['SECRET_KEY'] = 'your-secret-key-here'

script_dir = Path(__file__).resolve().parent
db_path = str(script_dir / "edulearn.db")
flask_app.config.setdefault("DATABASE_PATH", db_path)

flask_app.register_blueprint(app)
return flask_app
We run this in debug whenever app.py runs.
if __name__ == '__main__':
    flask_app = create_app()
    flask_app.run(debug=True, host='0.0.0.0', port=5000)

transform_db_question

This function takes a question from the database format and converts it into the format the frontend expects.
This is something we do a lot, so it’s moved into its own helper.
We check that all required fields are filled out, and return None if any are absent.
def transform_db_question(row):    """Convert a database question row into the format the frontend is expecting."""
if not row:
    return None

required_keys = [
    "QUE_ID", "QUE_question", "QUE_ans_1", "QUE_ans_2",
    "QUE_ans_3", "QUE_ans_4", "QUE_ans_correct", "TOP_name"
]

try:
    for key in required_keys:
        if key not in row or row[key] is None:
            return None

    return {
        "id": row["QUE_ID"],
        "name": row["QUE_question"],
        "options": [
            row["QUE_ans_1"],
            row["QUE_ans_2"],
            row["QUE_ans_3"],
            row["QUE_ans_4"],
        ],
        "correctOption": row["QUE_ans_correct"],
        "topic": row["TOP_name"],
    }
except (KeyError, TypeError):
    return None