Spaces:
Sleeping
Sleeping
| 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}") | |