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/ 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/ 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}")