Quiz / app.py
DigitalTransG2's picture
Update app.py
cd5724f verified
Raw
History Blame Contribute Delete
16.7 kB
import streamlit as st
import random
import time
import requests
from datetime import timedelta
# Page config
st.set_page_config(page_title="Student English Quiz Webpages")
st.title("Student English E-learning Web")
st.write("Note: quiz 1 is for Lesson 1 - Part 1, quiz 2 - Lesson 1 - Part 2, quiz 3 - Lesson 2 - Part 1, and so on...")
st.write("Each quiz can be done one time, if you want to review or see feedback, choose the quiz you have done.")
# --------------------------
# Configuration: timer
# --------------------------
QUIZ_DURATION_SECONDS = 60 * 10 # 1 hour
DEFAULT_PROMPT = "-- Select an option --"
# --------------------------
# Firebase config (from secrets.toml)
# --------------------------
try:
FIREBASE_URL = st.secrets["firebase_database_url"]
FIREBASE_AUTH = st.secrets["firebase_api_key"]
except Exception:
FIREBASE_URL = None
FIREBASE_AUTH = None
# --------------------------
# Helpers
# --------------------------
def format_seconds(s):
return str(timedelta(seconds=int(s)))
def firebase_get(path: str):
if not FIREBASE_URL or not FIREBASE_AUTH:
st.error("Firebase configuration missing in secrets. Please add firebase_database_url and firebase_api_key.")
return None
url = f"{FIREBASE_URL}/{path}.json?auth={FIREBASE_AUTH}"
try:
r = requests.get(url, timeout=10)
r.raise_for_status()
return r.json()
except Exception as e:
st.error(f"Error fetching {path} from Firebase: {e}")
return None
def firebase_patch(path: str, payload):
"""PATCH/merge payload into the given path (creates path if not exist)."""
if not FIREBASE_URL or not FIREBASE_AUTH:
st.error("Firebase configuration missing in secrets. Please add firebase_database_url and firebase_api_key.")
return False
url = f"{FIREBASE_URL}/{path}.json?auth={FIREBASE_AUTH}"
try:
r = requests.patch(url, json=payload, timeout=10)
r.raise_for_status()
return True
except Exception as e:
st.error(f"Error patching to {path} in Firebase: {e}")
return False
# Look up feedback in attempt dict in a case-insensitive and robust way
def get_feedback_from_attempt(attempt: dict):
"""Return feedback string if present in attempt dict.
This function accepts both 'feedback' and 'Feedback' (or any casing) and returns the value.
Returns None if no feedback key exists or value is empty.
"""
if not isinstance(attempt, dict):
return None
for k, v in attempt.items():
if isinstance(k, str) and k.lower() == "feedback":
# return non-empty value or None
return v if v is not None else None
return None
# --------------------------
# Quiz builder
# --------------------------
def build_quiz_from_master(master_quiz_data):
if master_quiz_data is None:
return None
questions_raw = None
if isinstance(master_quiz_data, dict) and "questions" in master_quiz_data:
questions_raw = master_quiz_data["questions"]
else:
questions_raw = master_quiz_data
qs = []
if isinstance(questions_raw, dict):
try:
items = sorted(questions_raw.items(), key=lambda kv: int(kv[0]))
except Exception:
items = list(questions_raw.items())
for _k, v in items:
qs.append(v)
elif isinstance(questions_raw, list):
qs = questions_raw
quiz = []
for q in qs:
try:
q_text = q.get("question") if isinstance(q, dict) else None
options = q.get("options") if isinstance(q, dict) else None
correct_idx = q.get("correct") if isinstance(q, dict) else None
if isinstance(options, dict):
try:
items = sorted(options.items(), key=lambda kv: int(kv[0]))
options = [it[1] for it in items]
except Exception:
options = list(options.values())
if not (q_text and options and correct_idx is not None):
continue
try:
correct_idx_int = int(correct_idx)
except Exception:
correct_idx_int = None
if correct_idx_int is None:
correct_text = q.get("correct")
else:
if 0 <= correct_idx_int < len(options):
correct_text = options[correct_idx_int]
else:
correct_text = options[0]
quiz.append({"q": q_text, "options": options, "correct": correct_text})
except Exception:
continue
return quiz
# --------------------------
# Session initialization
# --------------------------
if "student_info_submitted" not in st.session_state:
st.session_state.student_info_submitted = False
if "quiz_loaded" not in st.session_state:
st.session_state.quiz_loaded = False
if "view_only" not in st.session_state:
st.session_state.view_only = False
# --------------------------
# Student info form
# --------------------------
if not st.session_state.student_info_submitted:
with st.form("student_info_form"):
st.subheader("Student information")
student_name = st.text_input("Name", key="_name")
student_class = st.text_input("Class", key="_class")
student_id = st.text_input("Id", key="_id")
quiz_choice = st.selectbox("Do or Review Quiz", [f"Quiz{i}" for i in range(1, 7)], index=0)
submitted = st.form_submit_button("Start Quiz")
if submitted:
if not student_name or not student_id:
st.error("Please provide at least Name and Id.")
else:
st.session_state.student_name = student_name
st.session_state.student_class = student_class
st.session_state.student_id = student_id
st.session_state.quiz_choice = quiz_choice
# First check student_quizzes/<id> to see if the chosen quiz already exists
student_node = firebase_get(f"student_quizzes/{student_id}")
if student_node and isinstance(student_node, dict) and quiz_choice in student_node:
# The student had already done this quiz — load the stored attempt and show review only
attempt = student_node.get(quiz_choice)
if attempt and isinstance(attempt, dict):
st.session_state.view_only = True
st.session_state.attempt = attempt
# mark as submitted so UI shows review
st.session_state.submitted = True
st.session_state.score = attempt.get("Score")
st.session_state.used_time = attempt.get("Time")
st.session_state.student_info_submitted = True
st.experimental_rerun()
else:
# malformed stored attempt, fall back to loading master quiz
pass
# else load the master quiz as before
master = firebase_get("master_quizzes")
if master is None:
st.error("Could not load master_quizzes from Firebase.")
else:
selected = master.get(quiz_choice) if isinstance(master, dict) else None
if selected is None:
st.error(f"Could not find {quiz_choice} inside master_quizzes.")
else:
quiz = build_quiz_from_master(selected)
if not quiz:
st.error("Selected quiz is empty or malformed.")
else:
st.session_state.quiz = quiz
for i in range(len(quiz)):
st.session_state[f"q{i}"] = DEFAULT_PROMPT
st.session_state.start_time = time.time()
st.session_state.quiz_loaded = True
st.session_state.student_info_submitted = True
st.session_state.view_only = False
st.experimental_rerun()
st.stop()
# --------------------------
# At this point student info was submitted
# --------------------------
if not st.session_state.get("student_info_submitted"):
st.stop()
# If view_only and attempt data loaded, show review directly
if st.session_state.get("view_only") and st.session_state.get("submitted"):
st.subheader(f"Review for {st.session_state.quiz_choice} — Student: {st.session_state.student_name}")
st.write(f"Score: {st.session_state.score} / (reviewed)")
st.write(f"Time used: {format_seconds(int(st.session_state.used_time))}")
with st.expander("Review answers and Feedback"):
attempt = st.session_state.get("attempt", {})
responses = attempt.get("Responses", {})
# Normalize responses: accept dict (keys '0','1'...) or list of items
normalized = {}
if isinstance(responses, dict):
normalized = responses
elif isinstance(responses, list):
for idx, item in enumerate(responses):
# item may be dict with 'question','selected','correct'
normalized[str(idx)] = item
else:
# unexpected format — try to show something
st.write("No responses available or unknown format.")
# display in numeric order
try:
keys_sorted = sorted(normalized.keys(), key=lambda x: int(x))
except Exception:
keys_sorted = list(normalized.keys())
for i in keys_sorted:
r = normalized.get(i, {})
# be defensive: r may not be a dict
if isinstance(r, dict):
user = r.get("selected")
correct = r.get("correct")
qtext = r.get("question")
else:
user = None
correct = None
qtext = str(r)
user_display = user if user is not None else "No answer"
ok = "✅" if (user is not None and user == correct) else "❌"
st.write(f"**Q{int(i)+1}.** {qtext}")
st.write(f"Your answer: {user_display} {ok}")
st.write(f"Correct answer: {correct}")
st.write("")
# show feedback if present (case-insensitive search)
feedback_val = get_feedback_from_attempt(attempt)
st.subheader("Feedback")
if feedback_val is None:
st.write("No feedback yet")
else:
st.write(feedback_val)
if st.button("Choose another quiz"):
# allow student to pick another quiz — clear relevant session state
keys_to_remove = [k for k in list(st.session_state.keys()) if k.startswith("q") or k in ["quiz", "quiz_loaded", "start_time", "submitted", "score", "used_time", "attempt", "quiz_choice"]]
for k in keys_to_remove:
del st.session_state[k]
st.session_state.student_info_submitted = False
st.session_state.view_only = False
st.experimental_rerun()
st.stop()
# --------------------------
# At this point we have a loaded quiz for taking (not view-only)
# --------------------------
if not st.session_state.get("quiz_loaded"):
st.error("Quiz not loaded. Please refresh and try again.")
st.stop()
# UI for the quiz
st.subheader(f"Student: {st.session_state.student_name} — Quiz: {st.session_state.quiz_choice}")
st.write("You have 1 hour to finish. Your progress is stored only in this session while you take the quiz.")
# Sidebar: timer and controls
with st.sidebar:
st.header("Timer")
remaining = int(st.session_state.start_time + QUIZ_DURATION_SECONDS - time.time())
remaining = max(0, remaining)
st.subheader(format_seconds(remaining))
if st.session_state.get("submitted"):
st.success("Quiz submitted")
st.write(f"Score: {st.session_state.score} / {len(st.session_state.quiz)}")
st.write(f"Time used: {format_seconds(int(st.session_state.used_time))}")
if st.button("Reset and choose another quiz"):
keys_to_remove = [k for k in list(st.session_state.keys()) if k.startswith("q") or k in ["quiz", "quiz_loaded", "start_time", "submitted", "score", "used_time"]]
for k in keys_to_remove:
del st.session_state[k]
st.session_state.student_info_submitted = False
st.experimental_rerun()
# Auto-submit when time runs out
if remaining == 0 and not st.session_state.get("submitted"):
pass
st.markdown("---")
# Questions display
for i, q in enumerate(st.session_state.quiz):
st.write(f"**Question {i+1}.** {q['q']}")
display = [DEFAULT_PROMPT] + q["options"]
if st.session_state.get(f"q{i}") not in display:
st.session_state[f"q{i}"] = DEFAULT_PROMPT
st.selectbox(f"Select answer for q{i+1}", display, key=f"q{i}", label_visibility="collapsed")
st.write("")
st.markdown("---")
# Submission handling — compute score and store per-quiz key under student id (no overwriting other quizzes)
if st.session_state.get("submitted"):
st.success(f"You scored {st.session_state.score} out of {len(st.session_state.quiz)}.")
st.write(f"Time used: {format_seconds(int(st.session_state.used_time))}")
with st.expander("Review answers and Feedback"):
for i, q in enumerate(st.session_state.quiz):
user = st.session_state.get(f"q{i}", DEFAULT_PROMPT)
correct = q["correct"]
ok = "✅" if user == correct else "❌"
st.write(f"**Q{i+1}.** {q['q']}")
st.write(f"Your answer: {user} {ok}")
st.write(f"Correct answer: {correct}")
st.write("")
# show feedback from the attempt stored in session_state (if any)
attempt = st.session_state.get("attempt", {})
feedback_val = get_feedback_from_attempt(attempt)
st.subheader("Feedback")
if feedback_val is None:
st.write("No feedback yet")
else:
st.write(feedback_val)
else:
if st.button("Submit") or remaining == 0:
# compute score
score = 0
responses = {}
for i, q in enumerate(st.session_state.quiz):
user = st.session_state.get(f"q{i}", DEFAULT_PROMPT)
correct = q["correct"]
if user == correct:
score += 1
responses[str(i)] = {"question": q["q"], "selected": (None if user == DEFAULT_PROMPT else user), "correct": correct}
used_time = min(time.time() - st.session_state.start_time, QUIZ_DURATION_SECONDS)
st.session_state.submitted = True
st.session_state.score = score
st.session_state.used_time = used_time
# Prepare payload for this quiz under the student's node — do NOT include Timestamp and do NOT overwrite other quizzes
attempt_payload = {
"Score": score,
"Time": int(used_time),
"Responses": responses,
}
# store attempt in session_state so feedback is immediately available
st.session_state.attempt = attempt_payload
student_id = st.session_state.student_id
# ensure student-level metadata exists/updated
student_meta = {"Name": st.session_state.student_name, "Class": st.session_state.student_class}
# merge metadata and the single quiz field in one PATCH so we don't overwrite other quiz keys
patch_payload = {**student_meta, st.session_state.quiz_choice: attempt_payload}
patched = firebase_patch(f"student_quizzes/{student_id}", patch_payload)
if patched:
st.success(f"Saved student quiz under student_quizzes/{student_id}/{st.session_state.quiz_choice}")
else:
st.error("Failed to save the quiz result to Firebase.")
st.experimental_rerun()
# Footer note
st.caption("Saved structure in Firebase under /student_quizzes/<Id> with fields: Name, Class, and Quiz1..Quiz6 keys for attempts.")
# Optional Gradio launcher (no-op if demo missing)
if __name__ == "__main__":
try:
if "demo" in globals() and getattr(globals()["demo"], "launch", None):
try:
globals()["demo"].launch(server_name="0.0.0.0", server_port=7860, ssr_mode=False)
except Exception as e:
print(f"Gradio demo exists but failed to launch: {e}")
except Exception as e:
print(f"Error when attempting to launch Gradio demo: {e}")