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