lanna_lalala;- commited on
Commit ·
0aa6283
1
Parent(s): aa773f7
added folders
Browse files- app.py +371 -0
- assets/images/jmd/jmd_1.jpeg +3 -0
- assets/images/jmd/jmd_10.jpeg +3 -0
- assets/images/jmd/jmd_100.jpg +3 -0
- assets/images/jmd/jmd_1000.jpeg +3 -0
- assets/images/jmd/jmd_20.jpeg +3 -0
- assets/images/jmd/jmd_2000.jpeg +3 -0
- assets/images/jmd/jmd_5.jpeg +3 -0
- assets/images/jmd/jmd_50.jpg +3 -0
- assets/images/jmd/jmd_500.jpg +3 -0
- assets/images/jmd/jmd_5000.jpeg +3 -0
- assets/styles.css +745 -0
- dashboards/student_db.py +240 -0
- dashboards/teacher_db.py +294 -0
- isrgrootx1.pem +31 -0
- phase/Student_view/chatbot.py +215 -0
- phase/Student_view/game.py +391 -0
- phase/Student_view/games/MoneyMatch.py +208 -0
- phase/Student_view/games/budgetbuilder.py +565 -0
- phase/Student_view/games/debtdilemma.py +1062 -0
- phase/Student_view/games/profitpuzzle.py +536 -0
- phase/Student_view/lesson.py +649 -0
- phase/Student_view/practice_quiz.py +1 -0
- phase/Student_view/quiz.py +413 -0
- phase/Student_view/teacherlink.py +243 -0
- phase/Teacher_view/classmanage.py +188 -0
- phase/Teacher_view/contentmanage.py +688 -0
- phase/Teacher_view/studentlist.py +233 -0
- requirements.txt +41 -0
- tools/__init__.py +0 -0
- utils/api.py +660 -0
- utils/db.py +1327 -0
- utils/quizdata.py +260 -0
app.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
st.set_page_config(
|
| 3 |
+
page_title="Financial Education App",
|
| 4 |
+
page_icon="💹",
|
| 5 |
+
layout="centered",
|
| 6 |
+
initial_sidebar_state="expanded"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
from secrets import choice
|
| 10 |
+
from dashboards import student_db,teacher_db
|
| 11 |
+
from phase.Student_view import chatbot, lesson, quiz, game, teacherlink
|
| 12 |
+
from phase.Teacher_view import classmanage,studentlist,contentmanage
|
| 13 |
+
from phase.Student_view.games import profitpuzzle
|
| 14 |
+
from utils import db,api
|
| 15 |
+
import os, requests
|
| 16 |
+
|
| 17 |
+
from utils.api import BACKEND
|
| 18 |
+
st.sidebar.caption(f"Backend URL: {BACKEND}")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
ok = api.health().get("ok")
|
| 25 |
+
if ok:
|
| 26 |
+
st.sidebar.success("Backend: UP")
|
| 27 |
+
else:
|
| 28 |
+
st.sidebar.warning("Backend: ?")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
st.sidebar.error(f"Backend DOWN: {e}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# --- SESSION STATE INITIALIZATION ---
|
| 34 |
+
for key, default in [("user", None), ("current_page", "Welcome"),
|
| 35 |
+
("xp", 2450), ("streak", 7), ("current_game", None),
|
| 36 |
+
("temp_user", None)]:
|
| 37 |
+
if key not in st.session_state:
|
| 38 |
+
st.session_state[key] = default
|
| 39 |
+
|
| 40 |
+
# --- NAVIGATION ---
|
| 41 |
+
def setup_navigation():
|
| 42 |
+
if st.session_state.user:
|
| 43 |
+
public_pages = ["Welcome", "Login"]
|
| 44 |
+
else:
|
| 45 |
+
public_pages = ["Welcome", "Signup", "Login"]
|
| 46 |
+
|
| 47 |
+
nav_choice = st.sidebar.selectbox(
|
| 48 |
+
"Go to",
|
| 49 |
+
public_pages,
|
| 50 |
+
index=public_pages.index(st.session_state.current_page) if st.session_state.current_page in public_pages else 0
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# --- if quiz is in progress, show progress tracker ---
|
| 54 |
+
if st.session_state.get("current_page") == "Quiz":
|
| 55 |
+
qid = st.session_state.get("selected_quiz")
|
| 56 |
+
if qid is not None:
|
| 57 |
+
try:
|
| 58 |
+
quiz.show_quiz_progress_sidebar(qid) # renders into sidebar
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# --- if profit puzzle game is in progress, show progress tracker ---
|
| 64 |
+
if (
|
| 65 |
+
st.session_state.get("current_page") == "Game"
|
| 66 |
+
and st.session_state.get("current_game") == "profit_puzzle"
|
| 67 |
+
):
|
| 68 |
+
profitpuzzle.show_profit_progress_sidebar()
|
| 69 |
+
|
| 70 |
+
# Only override if user is already on a public page
|
| 71 |
+
if st.session_state.current_page in public_pages:
|
| 72 |
+
st.session_state.current_page = nav_choice
|
| 73 |
+
|
| 74 |
+
if st.session_state.user:
|
| 75 |
+
st.sidebar.markdown("---")
|
| 76 |
+
st.sidebar.subheader("Dashboard")
|
| 77 |
+
role = st.session_state.user["role"]
|
| 78 |
+
|
| 79 |
+
if role == "Student":
|
| 80 |
+
if st.sidebar.button("📊 Student Dashboard"):
|
| 81 |
+
st.session_state.current_page = "Student Dashboard"
|
| 82 |
+
if st.sidebar.button("📘 Lessons"):
|
| 83 |
+
st.session_state.current_page = "Lessons"
|
| 84 |
+
if st.sidebar.button("📝 Quiz"):
|
| 85 |
+
st.session_state.current_page = "Quiz"
|
| 86 |
+
if st.sidebar.button("💬 Chatbot"):
|
| 87 |
+
st.session_state.current_page = "Chatbot"
|
| 88 |
+
if st.sidebar.button("🏆 Game"):
|
| 89 |
+
st.session_state.current_page = "Game"
|
| 90 |
+
if st.sidebar.button("⌨️ Teacher Link"):
|
| 91 |
+
st.session_state.current_page = "Teacher Link"
|
| 92 |
+
|
| 93 |
+
elif role == "Teacher":
|
| 94 |
+
if st.sidebar.button("📚 Teacher Dashboard"):
|
| 95 |
+
st.session_state.current_page = "Teacher Dashboard"
|
| 96 |
+
if st.sidebar.button("Class management"):
|
| 97 |
+
st.session_state.current_page = "Class management"
|
| 98 |
+
if st.sidebar.button("Students List"):
|
| 99 |
+
st.session_state.current_page = "Students List"
|
| 100 |
+
if st.sidebar.button("Content Management"):
|
| 101 |
+
st.session_state.current_page = "Content Management"
|
| 102 |
+
|
| 103 |
+
if st.sidebar.button("Logout"):
|
| 104 |
+
st.session_state.user = None
|
| 105 |
+
st.session_state.current_page = "Welcome"
|
| 106 |
+
st.rerun()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# --- ROUTING ---
|
| 110 |
+
def main():
|
| 111 |
+
setup_navigation()
|
| 112 |
+
page = st.session_state.current_page
|
| 113 |
+
|
| 114 |
+
# --- WELCOME PAGE ---
|
| 115 |
+
if page == "Welcome":
|
| 116 |
+
st.title("💹 Welcome to FinEdu App")
|
| 117 |
+
if st.session_state.user:
|
| 118 |
+
st.success(f"Welcome back, {st.session_state.user['name']}! ✅")
|
| 119 |
+
st.write(
|
| 120 |
+
"This app helps you improve your **financial education and numeracy skills**. \n"
|
| 121 |
+
"👉 Use the sidebar to **Signup** or **Login** to get started."
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# --- SIGNUP PAGE ---
|
| 125 |
+
elif page == "Signup":
|
| 126 |
+
st.title("📝 Signup")
|
| 127 |
+
|
| 128 |
+
# remember the picked role between reruns
|
| 129 |
+
if "signup_role" not in st.session_state:
|
| 130 |
+
st.session_state.signup_role = None
|
| 131 |
+
|
| 132 |
+
if st.session_state.user:
|
| 133 |
+
st.success(f"Already logged in as {st.session_state.user['name']}.")
|
| 134 |
+
st.stop()
|
| 135 |
+
|
| 136 |
+
# Step 1: choose role
|
| 137 |
+
if not st.session_state.signup_role:
|
| 138 |
+
st.subheader("Who are you signing up as?")
|
| 139 |
+
c1, c2 = st.columns(2)
|
| 140 |
+
with c1:
|
| 141 |
+
if st.button("👩🎓 Student", use_container_width=True):
|
| 142 |
+
st.session_state.signup_role = "Student"
|
| 143 |
+
st.rerun()
|
| 144 |
+
with c2:
|
| 145 |
+
if st.button("👨🏫 Teacher", use_container_width=True):
|
| 146 |
+
st.session_state.signup_role = "Teacher"
|
| 147 |
+
st.rerun()
|
| 148 |
+
|
| 149 |
+
st.info("Pick your role to continue with the correct form.")
|
| 150 |
+
st.stop()
|
| 151 |
+
|
| 152 |
+
role = st.session_state.signup_role
|
| 153 |
+
|
| 154 |
+
# Step 2a: Student form
|
| 155 |
+
if role == "Student":
|
| 156 |
+
st.subheader("Student Signup")
|
| 157 |
+
with st.form("student_signup_form", clear_on_submit=False):
|
| 158 |
+
name = st.text_input("Full Name")
|
| 159 |
+
email = st.text_input("Email")
|
| 160 |
+
password = st.text_input("Password", type="password")
|
| 161 |
+
country = st.selectbox("Country", ["Jamaica", "USA", "UK", "India", "Canada", "Other"])
|
| 162 |
+
level = st.selectbox("Level", ["Beginner", "Intermediate", "Advanced"])
|
| 163 |
+
submitted = st.form_submit_button("Create Student Account")
|
| 164 |
+
|
| 165 |
+
if submitted:
|
| 166 |
+
if not (name.strip() and email.strip() and password.strip()):
|
| 167 |
+
st.error("⚠️ Please complete all required fields.")
|
| 168 |
+
st.stop()
|
| 169 |
+
|
| 170 |
+
if DISABLE_DB:
|
| 171 |
+
try:
|
| 172 |
+
api.signup_student(
|
| 173 |
+
name=name.strip(),
|
| 174 |
+
email=email.strip().lower(),
|
| 175 |
+
password=password,
|
| 176 |
+
level_label=level, # <-- keep these names
|
| 177 |
+
country_label=country,
|
| 178 |
+
)
|
| 179 |
+
st.success("✅ Signup successful! Please go to the **Login** page to continue.")
|
| 180 |
+
st.session_state.current_page = "Login"
|
| 181 |
+
st.session_state.signup_role = None
|
| 182 |
+
st.rerun()
|
| 183 |
+
except Exception as e:
|
| 184 |
+
st.error(f"❌ Signup failed: {e}")
|
| 185 |
+
else:
|
| 186 |
+
# Local DB path (unchanged)
|
| 187 |
+
conn = db.get_db_connection()
|
| 188 |
+
if not conn:
|
| 189 |
+
st.error("❌ Unable to connect to the database.")
|
| 190 |
+
st.stop()
|
| 191 |
+
try:
|
| 192 |
+
ok = db.create_student(
|
| 193 |
+
name=name, email=email, password=password,
|
| 194 |
+
level_label=level, country_label=country
|
| 195 |
+
)
|
| 196 |
+
if ok:
|
| 197 |
+
st.success("✅ Signup successful! Please go to the **Login** page to continue.")
|
| 198 |
+
st.session_state.current_page = "Login"
|
| 199 |
+
st.session_state.signup_role = None
|
| 200 |
+
st.rerun()
|
| 201 |
+
else:
|
| 202 |
+
st.error("❌ Failed to create user. Email may already exist.")
|
| 203 |
+
finally:
|
| 204 |
+
conn.close()
|
| 205 |
+
|
| 206 |
+
# Step 2b: Teacher form
|
| 207 |
+
elif role == "Teacher":
|
| 208 |
+
st.subheader("Teacher Signup")
|
| 209 |
+
with st.form("teacher_signup_form", clear_on_submit=False):
|
| 210 |
+
title = st.selectbox("Title", ["Mr", "Ms", "Miss", "Mrs", "Dr", "Prof", "Other"])
|
| 211 |
+
name = st.text_input("Full Name")
|
| 212 |
+
email = st.text_input("Email")
|
| 213 |
+
password = st.text_input("Password", type="password")
|
| 214 |
+
submitted = st.form_submit_button("Create Teacher Account")
|
| 215 |
+
|
| 216 |
+
if submitted:
|
| 217 |
+
if not (title.strip() and name.strip() and email.strip() and password.strip()):
|
| 218 |
+
st.error("⚠️ Please complete all required fields.")
|
| 219 |
+
st.stop()
|
| 220 |
+
|
| 221 |
+
if DISABLE_DB:
|
| 222 |
+
try:
|
| 223 |
+
api.signup_teacher(
|
| 224 |
+
title=title.strip(),
|
| 225 |
+
name=name.strip(),
|
| 226 |
+
email=email.strip().lower(),
|
| 227 |
+
password=password,
|
| 228 |
+
)
|
| 229 |
+
st.success("✅ Signup successful! Please go to the **Login** page to continue.")
|
| 230 |
+
st.session_state.current_page = "Login"
|
| 231 |
+
st.session_state.signup_role = None
|
| 232 |
+
st.rerun()
|
| 233 |
+
except Exception as e:
|
| 234 |
+
st.error(f"❌ Signup failed: {e}")
|
| 235 |
+
else:
|
| 236 |
+
conn = db.get_db_connection()
|
| 237 |
+
if not conn:
|
| 238 |
+
st.error("❌ Unable to connect to the database.")
|
| 239 |
+
st.stop()
|
| 240 |
+
try:
|
| 241 |
+
ok = db.create_teacher(
|
| 242 |
+
title=title, name=name, email=email, password=password
|
| 243 |
+
)
|
| 244 |
+
if ok:
|
| 245 |
+
st.success("✅ Signup successful! Please go to the **Login** page to continue.")
|
| 246 |
+
st.session_state.current_page = "Login"
|
| 247 |
+
st.session_state.signup_role = None
|
| 248 |
+
st.rerun()
|
| 249 |
+
else:
|
| 250 |
+
st.error("❌ Failed to create user. Email may already exist.")
|
| 251 |
+
finally:
|
| 252 |
+
conn.close()
|
| 253 |
+
|
| 254 |
+
# Allow changing role without going back manually
|
| 255 |
+
if st.button("⬅️ Choose a different role"):
|
| 256 |
+
st.session_state.signup_role = None
|
| 257 |
+
st.rerun()
|
| 258 |
+
|
| 259 |
+
# --- LOGIN PAGE ---
|
| 260 |
+
elif page == "Login":
|
| 261 |
+
st.title("🔑 Login")
|
| 262 |
+
if st.session_state.user:
|
| 263 |
+
st.success(f"Welcome, {st.session_state.user['name']}! ✅")
|
| 264 |
+
else:
|
| 265 |
+
with st.form("login_form"):
|
| 266 |
+
email = st.text_input("Email")
|
| 267 |
+
password = st.text_input("Password", type="password")
|
| 268 |
+
submit = st.form_submit_button("Login")
|
| 269 |
+
|
| 270 |
+
if submit:
|
| 271 |
+
if DISABLE_DB:
|
| 272 |
+
# Route login to your Backend Space
|
| 273 |
+
try:
|
| 274 |
+
user = api.login(email, password) # calls POST /auth/login
|
| 275 |
+
# Normalize to the structure your app already uses
|
| 276 |
+
st.session_state.user = {
|
| 277 |
+
"user_id": user["user_id"],
|
| 278 |
+
"name": user["name"],
|
| 279 |
+
"role": user["role"], # "Student" or "Teacher" from backend
|
| 280 |
+
"email": user["email"],
|
| 281 |
+
}
|
| 282 |
+
st.success(f"🎉 Logged in as {user['name']} ({user['role']})")
|
| 283 |
+
st.session_state.current_page = (
|
| 284 |
+
"Student Dashboard" if user["role"] == "Student" else "Teacher Dashboard"
|
| 285 |
+
)
|
| 286 |
+
st.rerun()
|
| 287 |
+
except Exception as e:
|
| 288 |
+
st.error(f"Login failed. {e}")
|
| 289 |
+
else:
|
| 290 |
+
# Local fallback: keep your old direct-DB logic
|
| 291 |
+
conn = db.get_db_connection()
|
| 292 |
+
if not conn:
|
| 293 |
+
st.error("❌ Unable to connect to the database.")
|
| 294 |
+
else:
|
| 295 |
+
try:
|
| 296 |
+
user = db.check_password(email, password)
|
| 297 |
+
if user:
|
| 298 |
+
st.session_state.user = {
|
| 299 |
+
"user_id": user["user_id"],
|
| 300 |
+
"name": user["name"],
|
| 301 |
+
"role": user["role"], # "Student" or "Teacher"
|
| 302 |
+
"email": user["email"],
|
| 303 |
+
}
|
| 304 |
+
st.success(f"🎉 Logged in as {user['name']} ({user['role']})")
|
| 305 |
+
st.session_state.current_page = (
|
| 306 |
+
"Student Dashboard" if user["role"] == "Student" else "Teacher Dashboard"
|
| 307 |
+
)
|
| 308 |
+
st.rerun()
|
| 309 |
+
else:
|
| 310 |
+
st.error("❌ Incorrect email or password, or account not found.")
|
| 311 |
+
finally:
|
| 312 |
+
conn.close()
|
| 313 |
+
|
| 314 |
+
# --- STUDENT DASHBOARD ---
|
| 315 |
+
elif page == "Student Dashboard":
|
| 316 |
+
if not st.session_state.user:
|
| 317 |
+
st.error("❌ Please login first.")
|
| 318 |
+
st.session_state.current_page = "Login"
|
| 319 |
+
st.rerun()
|
| 320 |
+
elif st.session_state.user["role"] != "Student":
|
| 321 |
+
st.error("🚫 Only students can access this page.")
|
| 322 |
+
st.session_state.current_page = "Welcome"
|
| 323 |
+
st.rerun()
|
| 324 |
+
else:
|
| 325 |
+
student_db.show_student_dashboard()
|
| 326 |
+
|
| 327 |
+
# --- TEACHER DASHBOARD ---
|
| 328 |
+
elif page == "Teacher Dashboard":
|
| 329 |
+
if not st.session_state.user:
|
| 330 |
+
st.error("❌ Please login first.")
|
| 331 |
+
st.session_state.current_page = "Login"
|
| 332 |
+
st.rerun()
|
| 333 |
+
elif st.session_state.user["role"] != "Teacher":
|
| 334 |
+
st.error("🚫 Only teachers can access this page.")
|
| 335 |
+
st.session_state.current_page = "Welcome"
|
| 336 |
+
st.rerun()
|
| 337 |
+
else:
|
| 338 |
+
teacher_db.show_teacher_dashboard()
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# --- PRIVATE PAGES ---
|
| 342 |
+
private_pages_map = {
|
| 343 |
+
"Lessons": lesson.show_page,
|
| 344 |
+
"Quiz": quiz.show_page,
|
| 345 |
+
"Chatbot": chatbot.show_page,
|
| 346 |
+
"Game": game.show_games,
|
| 347 |
+
"Teacher Link": teacherlink.show_code,
|
| 348 |
+
"Class management": classmanage.show_page,
|
| 349 |
+
"Students List": studentlist.show_page,
|
| 350 |
+
"Content Management": contentmanage.show_page
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
if page in private_pages_map:
|
| 354 |
+
if not st.session_state.user:
|
| 355 |
+
st.error("❌ Please login first.")
|
| 356 |
+
st.session_state.current_page = "Login"
|
| 357 |
+
st.rerun()
|
| 358 |
+
elif page in ["Lessons", "Quiz", "Chatbot", "Game", "Teacher Link"] and st.session_state.user["role"] == "Student":
|
| 359 |
+
private_pages_map[page]()
|
| 360 |
+
elif page in ["Class management", "Students List", "Content Management"] and st.session_state.user["role"] == "Teacher":
|
| 361 |
+
private_pages_map[page]()
|
| 362 |
+
else:
|
| 363 |
+
st.error("🚫 You don’t have access to this page.")
|
| 364 |
+
st.session_state.current_page = "Welcome"
|
| 365 |
+
st.rerun()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
if __name__ == "__main__":
|
| 370 |
+
main()
|
| 371 |
+
|
assets/images/jmd/jmd_1.jpeg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_10.jpeg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_100.jpg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_1000.jpeg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_20.jpeg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_2000.jpeg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_5.jpeg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_50.jpg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_500.jpg
ADDED
|
Git LFS Details
|
assets/images/jmd/jmd_5000.jpeg
ADDED
|
Git LFS Details
|
assets/styles.css
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* -----------------------------------------
|
| 2 |
+
Google Fonts
|
| 3 |
+
----------------------------------------- */
|
| 4 |
+
@import url("https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Poppins:wght@400;500;600;700&display=swap");
|
| 5 |
+
|
| 6 |
+
/* -----------------------------------------
|
| 7 |
+
Design Tokens
|
| 8 |
+
----------------------------------------- */
|
| 9 |
+
:root {
|
| 10 |
+
/* Main container */
|
| 11 |
+
--main-max-w: 1200px;
|
| 12 |
+
--main-pt: 2rem; /* default top padding */
|
| 13 |
+
--main-pb: 0rem; /* default bottom padding */
|
| 14 |
+
--main-px: 1.25rem;
|
| 15 |
+
|
| 16 |
+
/* Colors used throughout (left as-is to preserve your palette) */
|
| 17 |
+
--brand-green-500: #10b981;
|
| 18 |
+
--brand-green-600: #059669;
|
| 19 |
+
--brand-blue-500: #3b82f6;
|
| 20 |
+
--brand-blue-800: #1e40af;
|
| 21 |
+
--brand-sky-400: #22d3ee;
|
| 22 |
+
--brand-emerald-400: #4ade80;
|
| 23 |
+
--brand-emerald-500: #22c55e;
|
| 24 |
+
--gray-50: #f9fafb;
|
| 25 |
+
--gray-100: #f3f4f6;
|
| 26 |
+
--gray-200: #e5e7eb;
|
| 27 |
+
--gray-300: #d1d5db;
|
| 28 |
+
--gray-400: #dee2e6;
|
| 29 |
+
--gray-500: #6b7280;
|
| 30 |
+
--gray-600: #605e5c;
|
| 31 |
+
--gray-700: #495057;
|
| 32 |
+
--gray-800: #323130;
|
| 33 |
+
--text-dark: #2c3e50;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* -----------------------------------------
|
| 37 |
+
Base / Global
|
| 38 |
+
----------------------------------------- */
|
| 39 |
+
|
| 40 |
+
/* Main container: single source of truth */
|
| 41 |
+
.main .block-container,
|
| 42 |
+
.main > div {
|
| 43 |
+
max-width: var(--main-max-w);
|
| 44 |
+
margin: 0 auto;
|
| 45 |
+
padding: var(--main-pt) var(--main-px) var(--main-pb);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* Typography */
|
| 49 |
+
h1,
|
| 50 |
+
h2,
|
| 51 |
+
h3 {
|
| 52 |
+
color: var(--text-dark);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/* Hide Streamlit default elements (kept once) */
|
| 56 |
+
#MainMenu,
|
| 57 |
+
footer,
|
| 58 |
+
header {
|
| 59 |
+
visibility: hidden;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* -----------------------------------------
|
| 63 |
+
Buttons (shared)
|
| 64 |
+
----------------------------------------- */
|
| 65 |
+
.stButton > button {
|
| 66 |
+
border-radius: 8px;
|
| 67 |
+
border: none;
|
| 68 |
+
font-weight: 600;
|
| 69 |
+
transition: all 0.3s ease;
|
| 70 |
+
background: var(--brand-green-500);
|
| 71 |
+
color: #fff;
|
| 72 |
+
padding: 0.5rem 1rem;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
.stButton > button:hover {
|
| 76 |
+
transform: translateY(-2px);
|
| 77 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
| 78 |
+
background: var(--brand-green-600);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* Light gray styling for review buttons - multiple targets preserved */
|
| 82 |
+
.stButton > button:has-text("Review"),
|
| 83 |
+
.stButton > button[aria-label*="Review"],
|
| 84 |
+
div[data-testid*="lesson_review"] button {
|
| 85 |
+
background-color: #f8f9fa !important;
|
| 86 |
+
color: #6c757d !important;
|
| 87 |
+
border: 1px solid #dee2e6 !important;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.stButton > button:has-text("Review"):hover,
|
| 91 |
+
.stButton > button[aria-label*="Review"]:hover,
|
| 92 |
+
div[data-testid*="lesson_review"] button:hover {
|
| 93 |
+
background-color: #e9ecef !important;
|
| 94 |
+
color: #495057 !important;
|
| 95 |
+
transform: translateY(-1px);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
button[kind="secondary"] {
|
| 99 |
+
background-color: #f8f9fa !important;
|
| 100 |
+
color: #6c757d !important;
|
| 101 |
+
border: 1px solid #dee2e6 !important;
|
| 102 |
+
}
|
| 103 |
+
button[kind="secondary"]:hover {
|
| 104 |
+
background-color: #e9ecef !important;
|
| 105 |
+
color: #495057 !important;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/* -----------------------------------------
|
| 109 |
+
Welcome Card
|
| 110 |
+
----------------------------------------- */
|
| 111 |
+
.welcome-card {
|
| 112 |
+
background: linear-gradient(to right, #4ade80, #22d3ee);
|
| 113 |
+
padding: 2rem;
|
| 114 |
+
border-radius: 15px;
|
| 115 |
+
color: white;
|
| 116 |
+
text-align: center;
|
| 117 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 118 |
+
}
|
| 119 |
+
.welcome-btn {
|
| 120 |
+
background: #fff;
|
| 121 |
+
color: #111;
|
| 122 |
+
padding: 10px 20px;
|
| 123 |
+
border-radius: 8px;
|
| 124 |
+
border: none;
|
| 125 |
+
font-weight: bold;
|
| 126 |
+
cursor: pointer;
|
| 127 |
+
transition: background 0.2s;
|
| 128 |
+
}
|
| 129 |
+
.welcome-btn:hover {
|
| 130 |
+
background: #e0e0e0;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* -----------------------------------------
|
| 134 |
+
XP Card
|
| 135 |
+
----------------------------------------- */
|
| 136 |
+
.xp-card {
|
| 137 |
+
background: var(--brand-blue-500);
|
| 138 |
+
padding: 1rem;
|
| 139 |
+
border-radius: 12px;
|
| 140 |
+
color: white;
|
| 141 |
+
position: relative;
|
| 142 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 143 |
+
}
|
| 144 |
+
.xp-level {
|
| 145 |
+
font-weight: bold;
|
| 146 |
+
color: #0b612a;
|
| 147 |
+
}
|
| 148 |
+
.xp-text {
|
| 149 |
+
position: absolute;
|
| 150 |
+
right: 1rem;
|
| 151 |
+
top: 1rem;
|
| 152 |
+
}
|
| 153 |
+
.xp-bar {
|
| 154 |
+
background: var(--brand-blue-800);
|
| 155 |
+
height: 12px;
|
| 156 |
+
border-radius: 8px;
|
| 157 |
+
margin-top: 0.5rem;
|
| 158 |
+
}
|
| 159 |
+
.xp-fill {
|
| 160 |
+
background: var(--brand-emerald-500);
|
| 161 |
+
height: 12px;
|
| 162 |
+
border-radius: 8px;
|
| 163 |
+
transition: width 0.3s ease-in-out;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
/* -----------------------------------------
|
| 167 |
+
Daily Challenge
|
| 168 |
+
----------------------------------------- */
|
| 169 |
+
.challenge-card {
|
| 170 |
+
background: linear-gradient(135deg, #d946ef, #ec4899);
|
| 171 |
+
padding: 1.5rem;
|
| 172 |
+
border-radius: 12px;
|
| 173 |
+
color: white;
|
| 174 |
+
position: relative;
|
| 175 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 176 |
+
}
|
| 177 |
+
.challenge-header {
|
| 178 |
+
display: flex;
|
| 179 |
+
justify-content: space-between;
|
| 180 |
+
align-items: center;
|
| 181 |
+
}
|
| 182 |
+
.challenge-difficulty {
|
| 183 |
+
background: rgba(255, 255, 255, 0.2);
|
| 184 |
+
padding: 0.2rem 0.8rem;
|
| 185 |
+
border-radius: 12px;
|
| 186 |
+
font-size: 0.85rem;
|
| 187 |
+
}
|
| 188 |
+
.challenge-progress {
|
| 189 |
+
background: rgba(255, 255, 255, 0.3);
|
| 190 |
+
height: 10px;
|
| 191 |
+
border-radius: 8px;
|
| 192 |
+
margin: 1rem 0;
|
| 193 |
+
}
|
| 194 |
+
.challenge-fill {
|
| 195 |
+
background: var(--brand-emerald-500);
|
| 196 |
+
height: 10px;
|
| 197 |
+
border-radius: 8px;
|
| 198 |
+
transition: width 0.3s ease-in-out;
|
| 199 |
+
}
|
| 200 |
+
.challenge-percent {
|
| 201 |
+
position: absolute;
|
| 202 |
+
right: 1.5rem;
|
| 203 |
+
top: 7rem;
|
| 204 |
+
}
|
| 205 |
+
.challenge-footer {
|
| 206 |
+
display: flex;
|
| 207 |
+
justify-content: space-between;
|
| 208 |
+
align-items: center;
|
| 209 |
+
margin-top: 1rem;
|
| 210 |
+
}
|
| 211 |
+
.challenge-btn {
|
| 212 |
+
background: rgba(255, 255, 255, 0.2);
|
| 213 |
+
border: none;
|
| 214 |
+
padding: 0.5rem 1rem;
|
| 215 |
+
border-radius: 8px;
|
| 216 |
+
color: white;
|
| 217 |
+
font-weight: bold;
|
| 218 |
+
cursor: pointer;
|
| 219 |
+
transition: background 0.2s;
|
| 220 |
+
}
|
| 221 |
+
.challenge-btn:hover {
|
| 222 |
+
background: rgba(255, 255, 255, 0.3);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
/* -----------------------------------------
|
| 226 |
+
Game Card
|
| 227 |
+
----------------------------------------- */
|
| 228 |
+
.game-card {
|
| 229 |
+
background: #ffffff;
|
| 230 |
+
padding: 1.5rem;
|
| 231 |
+
border-radius: 12px;
|
| 232 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 233 |
+
text-align: center;
|
| 234 |
+
}
|
| 235 |
+
.game-card img {
|
| 236 |
+
margin-bottom: 1rem;
|
| 237 |
+
}
|
| 238 |
+
.game-difficulty {
|
| 239 |
+
background-color: lightgray;
|
| 240 |
+
padding: 5px;
|
| 241 |
+
border-radius: 5px;
|
| 242 |
+
display: inline-block;
|
| 243 |
+
}
|
| 244 |
+
.game-difficulty.easy {
|
| 245 |
+
background-color: lightgreen;
|
| 246 |
+
}
|
| 247 |
+
.game-difficulty.medium {
|
| 248 |
+
background-color: #ffb347;
|
| 249 |
+
}
|
| 250 |
+
.game-difficulty.hard {
|
| 251 |
+
background-color: lightcoral;
|
| 252 |
+
}
|
| 253 |
+
.game-btn {
|
| 254 |
+
background: #10b981;
|
| 255 |
+
color: white;
|
| 256 |
+
padding: 10px 20px;
|
| 257 |
+
border: none;
|
| 258 |
+
border-radius: 8px;
|
| 259 |
+
font-weight: bold;
|
| 260 |
+
cursor: pointer;
|
| 261 |
+
transition: background 0.2s;
|
| 262 |
+
}
|
| 263 |
+
.game-btn:hover {
|
| 264 |
+
background: #059669;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/* -----------------------------------------
|
| 268 |
+
Leaderboard
|
| 269 |
+
----------------------------------------- */
|
| 270 |
+
.leaderboard-card {
|
| 271 |
+
background: var(--gray-50);
|
| 272 |
+
padding: 1.5rem;
|
| 273 |
+
border-radius: 12px;
|
| 274 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 275 |
+
}
|
| 276 |
+
.leaderboard-item {
|
| 277 |
+
margin-bottom: 0.5rem;
|
| 278 |
+
}
|
| 279 |
+
.leaderboard-you {
|
| 280 |
+
color: var(--brand-green-500);
|
| 281 |
+
font-weight: bold;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/* -----------------------------------------
|
| 285 |
+
Tips
|
| 286 |
+
----------------------------------------- */
|
| 287 |
+
.tips-card {
|
| 288 |
+
background: var(--gray-50);
|
| 289 |
+
padding: 1.5rem;
|
| 290 |
+
border-radius: 12px;
|
| 291 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 292 |
+
}
|
| 293 |
+
.tips-item {
|
| 294 |
+
margin-bottom: 0.5rem;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
/* -----------------------------------------
|
| 298 |
+
lesson.py CSS
|
| 299 |
+
----------------------------------------- */
|
| 300 |
+
.stMetric {
|
| 301 |
+
background-color: #f8f9fa;
|
| 302 |
+
padding: 1rem;
|
| 303 |
+
border-radius: 8px;
|
| 304 |
+
border-left: 4px solid #007bff;
|
| 305 |
+
}
|
| 306 |
+
.stProgress > div > div {
|
| 307 |
+
background-color: #007bff;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.lesson-card {
|
| 311 |
+
transition: all 0.3s ease;
|
| 312 |
+
cursor: pointer;
|
| 313 |
+
}
|
| 314 |
+
.lesson-card:hover {
|
| 315 |
+
transform: translateY(-4px) !important;
|
| 316 |
+
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
|
| 317 |
+
border-color: #0078d4 !important;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.topic-content {
|
| 321 |
+
font-size: 1.25rem;
|
| 322 |
+
line-height: 1.8;
|
| 323 |
+
padding: 1.5rem;
|
| 324 |
+
background: #fffef5;
|
| 325 |
+
border-radius: 16px;
|
| 326 |
+
border: 2px solid #f0e68c;
|
| 327 |
+
margin-bottom: 1.5rem;
|
| 328 |
+
font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif;
|
| 329 |
+
}
|
| 330 |
+
.topic-content p {
|
| 331 |
+
margin-bottom: 1rem;
|
| 332 |
+
}
|
| 333 |
+
.topic-content ul,
|
| 334 |
+
.topic-content ol {
|
| 335 |
+
padding-left: 1.5rem;
|
| 336 |
+
margin-bottom: 1rem;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
/* buttons */
|
| 340 |
+
.topic-nav-btn button {
|
| 341 |
+
font-size: 1.3rem;
|
| 342 |
+
font-weight: 600;
|
| 343 |
+
padding: 1rem;
|
| 344 |
+
border-radius: 999px;
|
| 345 |
+
transition: all 0.2s ease-in-out;
|
| 346 |
+
border: none;
|
| 347 |
+
width: 100%;
|
| 348 |
+
height: 3.5rem;
|
| 349 |
+
}
|
| 350 |
+
/* Previous button (pink, left) */
|
| 351 |
+
.prev-btn button {
|
| 352 |
+
background-color: #ff99cc !important;
|
| 353 |
+
color: white !important;
|
| 354 |
+
text-align: left;
|
| 355 |
+
}
|
| 356 |
+
/* Next button (blue, right) */
|
| 357 |
+
.next-btn button {
|
| 358 |
+
background-color: #66ccff !important;
|
| 359 |
+
color: white !important;
|
| 360 |
+
text-align: right;
|
| 361 |
+
}
|
| 362 |
+
.topic-nav-btn button:hover {
|
| 363 |
+
transform: scale(1.05);
|
| 364 |
+
opacity: 0.9;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
/* -----------------------------------------
|
| 368 |
+
code.py
|
| 369 |
+
----------------------------------------- */
|
| 370 |
+
.join-class-container {
|
| 371 |
+
max-width: 600px;
|
| 372 |
+
margin: 2rem auto;
|
| 373 |
+
padding: 0 1rem;
|
| 374 |
+
}
|
| 375 |
+
.header-text {
|
| 376 |
+
text-align: center;
|
| 377 |
+
color: #605e5c;
|
| 378 |
+
font-size: 18px;
|
| 379 |
+
margin-bottom: 2rem;
|
| 380 |
+
line-height: 1.5;
|
| 381 |
+
}
|
| 382 |
+
.join-card {
|
| 383 |
+
background: white;
|
| 384 |
+
border: 1px solid #e1e5e9;
|
| 385 |
+
border-radius: 12px;
|
| 386 |
+
padding: 2rem;
|
| 387 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 388 |
+
margin-bottom: 1rem;
|
| 389 |
+
}
|
| 390 |
+
.card-header {
|
| 391 |
+
display: flex;
|
| 392 |
+
align-items: center;
|
| 393 |
+
margin-bottom: 1rem;
|
| 394 |
+
}
|
| 395 |
+
.card-icon {
|
| 396 |
+
font-size: 24px;
|
| 397 |
+
margin-right: 12px;
|
| 398 |
+
color: #323130;
|
| 399 |
+
}
|
| 400 |
+
.card-title {
|
| 401 |
+
font-size: 24px;
|
| 402 |
+
font-weight: 600;
|
| 403 |
+
color: #323130;
|
| 404 |
+
margin: 0;
|
| 405 |
+
}
|
| 406 |
+
.card-subtitle {
|
| 407 |
+
color: #605e5c;
|
| 408 |
+
font-size: 16px;
|
| 409 |
+
margin-bottom: 1.5rem;
|
| 410 |
+
line-height: 1.4;
|
| 411 |
+
}
|
| 412 |
+
.input-container {
|
| 413 |
+
margin-bottom: 1.5rem;
|
| 414 |
+
}
|
| 415 |
+
.footer-text {
|
| 416 |
+
text-align: center;
|
| 417 |
+
color: #605e5c;
|
| 418 |
+
font-size: 14px;
|
| 419 |
+
margin-top: 1rem;
|
| 420 |
+
line-height: 1.4;
|
| 421 |
+
}
|
| 422 |
+
.stTextInput > div > div > input {
|
| 423 |
+
border-radius: 8px;
|
| 424 |
+
border: 1px solid #d1d5db;
|
| 425 |
+
padding: 12px 16px;
|
| 426 |
+
font-size: 16px;
|
| 427 |
+
}
|
| 428 |
+
.stTextInput > div > div > input:focus {
|
| 429 |
+
border-color: #0078d4;
|
| 430 |
+
box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2);
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
/* -----------------------------------------
|
| 434 |
+
Teacher Dashboard
|
| 435 |
+
----------------------------------------- */
|
| 436 |
+
|
| 437 |
+
/* Header styling with gradient */
|
| 438 |
+
.header-container {
|
| 439 |
+
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 440 |
+
padding: 2rem;
|
| 441 |
+
border-radius: 1rem;
|
| 442 |
+
margin-bottom: 2rem;
|
| 443 |
+
color: white;
|
| 444 |
+
position: relative;
|
| 445 |
+
}
|
| 446 |
+
.header-content {
|
| 447 |
+
display: flex;
|
| 448 |
+
justify-content: space-between;
|
| 449 |
+
align-items: flex-start;
|
| 450 |
+
width: 100%;
|
| 451 |
+
}
|
| 452 |
+
.header-left {
|
| 453 |
+
flex: 1;
|
| 454 |
+
}
|
| 455 |
+
.header-title {
|
| 456 |
+
font-size: 2rem;
|
| 457 |
+
font-weight: 700;
|
| 458 |
+
margin-bottom: 0.5rem;
|
| 459 |
+
color: white;
|
| 460 |
+
}
|
| 461 |
+
.header-subtitle {
|
| 462 |
+
font-size: 1.1rem;
|
| 463 |
+
opacity: 0.9;
|
| 464 |
+
color: white;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
/* Metric cards (dashboard version) */
|
| 468 |
+
.metric-card {
|
| 469 |
+
background: white;
|
| 470 |
+
padding: 1.5rem;
|
| 471 |
+
border-radius: 1rem;
|
| 472 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
| 473 |
+
border: 1px solid #f3f4f6;
|
| 474 |
+
text-align: center;
|
| 475 |
+
margin-bottom: 1rem;
|
| 476 |
+
}
|
| 477 |
+
.metric-icon {
|
| 478 |
+
font-size: 2rem;
|
| 479 |
+
color: var(--brand-green-500);
|
| 480 |
+
}
|
| 481 |
+
.metric-value {
|
| 482 |
+
font-size: 2rem;
|
| 483 |
+
font-weight: 700;
|
| 484 |
+
color: var(--brand-green-500);
|
| 485 |
+
}
|
| 486 |
+
.metric-label {
|
| 487 |
+
font-size: 0.9rem;
|
| 488 |
+
color: #6b7280;
|
| 489 |
+
font-weight: 500;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
/* Chart containers */
|
| 493 |
+
.chart-title {
|
| 494 |
+
font-size: 1.25rem;
|
| 495 |
+
font-weight: 600;
|
| 496 |
+
color: #1f2937;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
/* Progress bars */
|
| 500 |
+
.progress-label {
|
| 501 |
+
font-size: 0.9rem;
|
| 502 |
+
color: #6b7280;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
/* Activity section */
|
| 506 |
+
.activity-item {
|
| 507 |
+
padding: 0.75rem;
|
| 508 |
+
background: #f9fafb;
|
| 509 |
+
border-radius: 0.5rem;
|
| 510 |
+
margin-bottom: 0.5rem;
|
| 511 |
+
border-left: 3px solid var(--brand-green-500);
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
/* Quick actions */
|
| 515 |
+
.quick-action-btn {
|
| 516 |
+
width: 100%;
|
| 517 |
+
margin-bottom: 0.5rem;
|
| 518 |
+
background: #f3f4f6;
|
| 519 |
+
border: none;
|
| 520 |
+
padding: 0.75rem;
|
| 521 |
+
border-radius: 0.5rem;
|
| 522 |
+
text-align: left;
|
| 523 |
+
font-weight: 500;
|
| 524 |
+
}
|
| 525 |
+
.quick-action-btn:hover {
|
| 526 |
+
background: #e5e7eb;
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.main .block-container {
|
| 530 |
+
padding-top: 4rem;
|
| 531 |
+
padding-bottom: 0rem;
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
/* -----------------------------------------
|
| 535 |
+
Debt Dilemma (Game)
|
| 536 |
+
----------------------------------------- */
|
| 537 |
+
.game-header {
|
| 538 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 539 |
+
padding: 1.5rem;
|
| 540 |
+
border-radius: 15px;
|
| 541 |
+
color: white;
|
| 542 |
+
text-align: center;
|
| 543 |
+
margin-bottom: 2rem;
|
| 544 |
+
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
| 545 |
+
}
|
| 546 |
+
.game-title {
|
| 547 |
+
font-size: 2.5rem;
|
| 548 |
+
font-weight: bold;
|
| 549 |
+
margin-bottom: 0.5rem;
|
| 550 |
+
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.metric-card {
|
| 554 |
+
background: white;
|
| 555 |
+
padding: 1rem;
|
| 556 |
+
border-radius: 8px;
|
| 557 |
+
text-align: center;
|
| 558 |
+
margin: 0.5rem 0;
|
| 559 |
+
border: 1px solid #e0e0e0;
|
| 560 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 561 |
+
}
|
| 562 |
+
.metric-card h2,
|
| 563 |
+
.metric-card h3,
|
| 564 |
+
.metric-card h4 {
|
| 565 |
+
margin: 0.25rem 0;
|
| 566 |
+
color: #333;
|
| 567 |
+
}
|
| 568 |
+
.metric-card small {
|
| 569 |
+
color: #666;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
.stButton > button:hover {
|
| 573 |
+
transform: translateY(-2px);
|
| 574 |
+
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
|
| 575 |
+
}
|
| 576 |
+
.success-btn > button {
|
| 577 |
+
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%) !important;
|
| 578 |
+
}
|
| 579 |
+
.warning-btn > button {
|
| 580 |
+
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%) !important;
|
| 581 |
+
}
|
| 582 |
+
.danger-btn > button {
|
| 583 |
+
background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%) !important;
|
| 584 |
+
}
|
| 585 |
+
.event-card {
|
| 586 |
+
background: #f8f9fa;
|
| 587 |
+
color: #333;
|
| 588 |
+
padding: 1.5rem;
|
| 589 |
+
border-radius: 8px;
|
| 590 |
+
margin: 1rem 0;
|
| 591 |
+
border: 1px solid #dee2e6;
|
| 592 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 593 |
+
}
|
| 594 |
+
.event-title {
|
| 595 |
+
font-size: 1.5rem;
|
| 596 |
+
font-weight: bold;
|
| 597 |
+
margin-bottom: 0.5rem;
|
| 598 |
+
color: #495057;
|
| 599 |
+
}
|
| 600 |
+
.expense-card {
|
| 601 |
+
background: white;
|
| 602 |
+
padding: 1rem;
|
| 603 |
+
border-radius: 8px;
|
| 604 |
+
margin: 0.5rem 0;
|
| 605 |
+
border: 1px solid #e0e0e0;
|
| 606 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
| 607 |
+
}
|
| 608 |
+
.expense-card h4 {
|
| 609 |
+
margin: 0 0 0.5rem 0;
|
| 610 |
+
color: #333;
|
| 611 |
+
}
|
| 612 |
+
.expense-card p {
|
| 613 |
+
margin: 0.25rem 0;
|
| 614 |
+
color: #666;
|
| 615 |
+
}
|
| 616 |
+
.achievement-badge {
|
| 617 |
+
background: #f8f9fa;
|
| 618 |
+
padding: 0.5rem 1rem;
|
| 619 |
+
border-radius: 20px;
|
| 620 |
+
margin: 0.25rem;
|
| 621 |
+
display: inline-block;
|
| 622 |
+
font-weight: bold;
|
| 623 |
+
border: 1px solid #dee2e6;
|
| 624 |
+
color: #495057;
|
| 625 |
+
}
|
| 626 |
+
.stProgress > div > div > div {
|
| 627 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 628 |
+
border-radius: 10px;
|
| 629 |
+
}
|
| 630 |
+
.stAlert {
|
| 631 |
+
border-radius: 8px;
|
| 632 |
+
border: none;
|
| 633 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
/* ---------- Leaderboard styles ---------- */
|
| 637 |
+
.leaderboard {
|
| 638 |
+
--lb-bg: #ffffff;
|
| 639 |
+
--lb-border: #e5e7eb; /* gray-200 */
|
| 640 |
+
--lb-accent: linear-gradient(135deg, #22c55e, #059669);
|
| 641 |
+
--lb-you-bg: #ecfdf5; /* emerald-50 */
|
| 642 |
+
--lb-text: #111827; /* gray-900 */
|
| 643 |
+
|
| 644 |
+
border: 1px solid var(--lb-border);
|
| 645 |
+
border-radius: 16px;
|
| 646 |
+
background: var(--lb-bg);
|
| 647 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06);
|
| 648 |
+
overflow: hidden;
|
| 649 |
+
color: var(--lb-text);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
.leaderboard .lb-head {
|
| 653 |
+
display: flex;
|
| 654 |
+
align-items: center;
|
| 655 |
+
gap: 10px;
|
| 656 |
+
padding: 14px 18px;
|
| 657 |
+
background: var(--lb-accent);
|
| 658 |
+
color: #fff;
|
| 659 |
+
font-weight: 800;
|
| 660 |
+
letter-spacing: 0.2px;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
.lb-row {
|
| 664 |
+
display: grid;
|
| 665 |
+
grid-template-columns: 56px 1fr auto auto;
|
| 666 |
+
gap: 12px;
|
| 667 |
+
align-items: center;
|
| 668 |
+
padding: 12px 18px;
|
| 669 |
+
border-top: 1px solid var(--lb-border);
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.lb-row:first-of-type {
|
| 673 |
+
border-top: none;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.lb-row.is-you {
|
| 677 |
+
background: var(--lb-you-bg);
|
| 678 |
+
position: relative;
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.lb-rank {
|
| 682 |
+
width: 36px;
|
| 683 |
+
height: 36px;
|
| 684 |
+
display: grid;
|
| 685 |
+
place-items: center;
|
| 686 |
+
border-radius: 12px;
|
| 687 |
+
background: #f3f4f6; /* gray-100 */
|
| 688 |
+
font-weight: 800;
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
.lb-rank.medal-1 {
|
| 692 |
+
background: #f59e0b;
|
| 693 |
+
color: #fff;
|
| 694 |
+
} /* gold */
|
| 695 |
+
.lb-rank.medal-2 {
|
| 696 |
+
background: #9ca3af;
|
| 697 |
+
color: #fff;
|
| 698 |
+
} /* silver */
|
| 699 |
+
.lb-rank.medal-3 {
|
| 700 |
+
background: #b45309;
|
| 701 |
+
color: #fff;
|
| 702 |
+
} /* bronze */
|
| 703 |
+
|
| 704 |
+
.lb-name {
|
| 705 |
+
font-weight: 700;
|
| 706 |
+
overflow: hidden;
|
| 707 |
+
text-overflow: ellipsis;
|
| 708 |
+
white-space: nowrap;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.lb-level {
|
| 712 |
+
font-size: 12px;
|
| 713 |
+
padding: 4px 10px;
|
| 714 |
+
border-radius: 999px;
|
| 715 |
+
background: #eef2ff; /* indigo-50 */
|
| 716 |
+
color: #3730a3; /* indigo-800 */
|
| 717 |
+
font-weight: 800;
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
.lb-xp {
|
| 721 |
+
font-variant-numeric: tabular-nums;
|
| 722 |
+
font-weight: 700;
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
.lb-you-pill {
|
| 726 |
+
position: absolute;
|
| 727 |
+
right: 10px;
|
| 728 |
+
top: 10px;
|
| 729 |
+
background: #10b981; /* emerald-500 */
|
| 730 |
+
color: #fff;
|
| 731 |
+
font-size: 11px;
|
| 732 |
+
padding: 2px 8px;
|
| 733 |
+
border-radius: 999px;
|
| 734 |
+
font-weight: 800;
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
/* Small screens: tuck columns a bit tighter */
|
| 738 |
+
@media (max-width: 640px) {
|
| 739 |
+
.lb-row {
|
| 740 |
+
grid-template-columns: 48px 1fr auto;
|
| 741 |
+
}
|
| 742 |
+
.lb-xp {
|
| 743 |
+
display: none; /* hide XP column on very small screens */
|
| 744 |
+
}
|
| 745 |
+
}
|
dashboards/student_db.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from phase.Student_view import chatbot, lesson, quiz
|
| 4 |
+
from utils import db as dbapi
|
| 5 |
+
import utils.api as api # <-- backend Space client
|
| 6 |
+
|
| 7 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 8 |
+
|
| 9 |
+
# --- Load external CSS ---
|
| 10 |
+
def load_css(file_name: str):
|
| 11 |
+
try:
|
| 12 |
+
with open(file_name, "r", encoding="utf-8") as f:
|
| 13 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 14 |
+
except FileNotFoundError:
|
| 15 |
+
st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
|
| 16 |
+
|
| 17 |
+
def show_student_dashboard():
|
| 18 |
+
# Load CSS
|
| 19 |
+
css_path = os.path.join("assets", "styles.css")
|
| 20 |
+
load_css(css_path)
|
| 21 |
+
|
| 22 |
+
# Current user
|
| 23 |
+
user = st.session_state.user
|
| 24 |
+
name = user["name"]
|
| 25 |
+
student_id = user["user_id"]
|
| 26 |
+
|
| 27 |
+
# --- Real metrics from DB ---
|
| 28 |
+
# Requires helper funcs in utils/db.py: user_xp_and_level, recent_lessons_for_student, list_assignments_for_student
|
| 29 |
+
if USE_LOCAL_DB and hasattr(dbapi, "user_xp_and_level"):
|
| 30 |
+
stats = dbapi.user_xp_and_level(student_id)
|
| 31 |
+
else:
|
| 32 |
+
# Try backend; fall back to defaults if not available yet
|
| 33 |
+
try:
|
| 34 |
+
stats = api.user_stats(student_id)
|
| 35 |
+
except Exception:
|
| 36 |
+
stats = {"xp": 0, "level": 1, "streak": 0}
|
| 37 |
+
|
| 38 |
+
xp = int(stats.get("xp", 0))
|
| 39 |
+
level = int(stats.get("level", 1))
|
| 40 |
+
study_streak = int(stats.get("streak", 0))
|
| 41 |
+
|
| 42 |
+
# # Cap for the visual bar
|
| 43 |
+
# max_xp = max(500, ((xp // 500) + 1) * 500)
|
| 44 |
+
|
| 45 |
+
# Assignments for “My Work”
|
| 46 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_assignments_for_student"):
|
| 47 |
+
rows = dbapi.list_assignments_for_student(student_id)
|
| 48 |
+
else:
|
| 49 |
+
try:
|
| 50 |
+
rows = api.list_assignments_for_student(student_id)
|
| 51 |
+
except Exception:
|
| 52 |
+
rows = []
|
| 53 |
+
|
| 54 |
+
def _pct_from_row(r: dict):
|
| 55 |
+
sp = r.get("score_pct")
|
| 56 |
+
if sp is not None:
|
| 57 |
+
try:
|
| 58 |
+
return int(round(float(sp)))
|
| 59 |
+
except Exception:
|
| 60 |
+
pass
|
| 61 |
+
s, t = r.get("score"), r.get("total")
|
| 62 |
+
if s is not None and t not in (None, 0):
|
| 63 |
+
try:
|
| 64 |
+
return int(round((float(s) / float(t)) * 100))
|
| 65 |
+
except Exception:
|
| 66 |
+
return None
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
if USE_LOCAL_DB and hasattr(dbapi, "student_quiz_average"):
|
| 70 |
+
quiz_score = dbapi.student_quiz_average(student_id)
|
| 71 |
+
else:
|
| 72 |
+
try:
|
| 73 |
+
quiz_score = api.student_quiz_average(student_id)
|
| 74 |
+
except Exception:
|
| 75 |
+
quiz_score = 0
|
| 76 |
+
|
| 77 |
+
lessons_completed = sum(1 for r in rows if r.get("status") == "completed" or _pct_from_row(r) == 100)
|
| 78 |
+
total_lessons = len(rows)
|
| 79 |
+
|
| 80 |
+
# Recent lessons assigned to this student
|
| 81 |
+
if USE_LOCAL_DB and hasattr(dbapi, "recent_lessons_for_student"):
|
| 82 |
+
recent_lessons = dbapi.recent_lessons_for_student(student_id, limit=5)
|
| 83 |
+
else:
|
| 84 |
+
try:
|
| 85 |
+
recent_lessons = api.recent_lessons_for_student(student_id, limit=5)
|
| 86 |
+
except Exception:
|
| 87 |
+
recent_lessons = []
|
| 88 |
+
|
| 89 |
+
# Daily Challenge derived from real data
|
| 90 |
+
challenge_difficulty = "Easy" if level < 3 else ("Medium" if level < 6 else "Hard")
|
| 91 |
+
challenge_title = "Complete 1 quiz with 80%+"
|
| 92 |
+
challenge_desc = "Prove you remember yesterday's key points."
|
| 93 |
+
challenge_progress = 100 if quiz_score >= 80 else 0
|
| 94 |
+
reward = "+50 XP"
|
| 95 |
+
time_left = "Ends 11:59 PM"
|
| 96 |
+
|
| 97 |
+
# Achievements from real data
|
| 98 |
+
achievements = [
|
| 99 |
+
{"title": "First Steps", "desc": "Complete your first lesson", "earned": lessons_completed > 0},
|
| 100 |
+
{"title": "Quiz Whiz", "desc": "Score 80%+ on any quiz", "earned": quiz_score >= 80},
|
| 101 |
+
{"title": "On a Roll", "desc": "Study 3 days in a row", "earned": study_streak >= 3},
|
| 102 |
+
{"title": "Consistency", "desc": "Finish 5 assignments", "earned": total_lessons >= 5 and lessons_completed >= 5},
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
# --- Welcome Card ---
|
| 106 |
+
st.markdown(
|
| 107 |
+
f"""
|
| 108 |
+
<div class="welcome-card">
|
| 109 |
+
<h2>Welcome back, {name}!</h2>
|
| 110 |
+
<p style="font-size: 20px;">{"Ready to continue your financial journey?" if lessons_completed > 0 else "Start your financial journey."}</p>
|
| 111 |
+
</div>
|
| 112 |
+
""",
|
| 113 |
+
unsafe_allow_html=True
|
| 114 |
+
)
|
| 115 |
+
st.write("")
|
| 116 |
+
|
| 117 |
+
# --- Quick Action Buttons ---
|
| 118 |
+
actions = [
|
| 119 |
+
("📚 Start a Lesson", "Lessons"),
|
| 120 |
+
("📝 Attempt a Quiz", "Quiz"),
|
| 121 |
+
("💬 Talk to AI Tutor", "Chatbot"),
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
# 5 columns: spacer, button, button, button, spacer
|
| 125 |
+
cols = st.columns([1, 2, 2, 2, 1])
|
| 126 |
+
for i, (label, page) in enumerate(actions):
|
| 127 |
+
with cols[i+1]: # skip the left spacer
|
| 128 |
+
if st.button(label, key=f"action_{i}"):
|
| 129 |
+
st.session_state.current_page = page
|
| 130 |
+
st.rerun()
|
| 131 |
+
|
| 132 |
+
st.write("")
|
| 133 |
+
|
| 134 |
+
# --- Progress Summary Cards ---
|
| 135 |
+
progress_cols = st.columns(3)
|
| 136 |
+
progress_cols[0].metric("📘 Lessons Completed", f"{lessons_completed}/{total_lessons}")
|
| 137 |
+
progress_cols[1].metric("📊 Quiz Score", f"{quiz_score}/100")
|
| 138 |
+
progress_cols[2].metric("🔥 Study Streak", f"{study_streak} days")
|
| 139 |
+
st.write("")
|
| 140 |
+
|
| 141 |
+
# --- XP Bar ---
|
| 142 |
+
# stats already fetched above
|
| 143 |
+
xp = int(stats.get("xp", 0))
|
| 144 |
+
level = int(stats.get("level", 1))
|
| 145 |
+
study_streak = int(stats.get("streak", 0))
|
| 146 |
+
|
| 147 |
+
# prefer server-provided per-level fields
|
| 148 |
+
into = int(stats.get("into", -1))
|
| 149 |
+
need = int(stats.get("need", -1))
|
| 150 |
+
|
| 151 |
+
# fallback if backend hasn't been updated to include into/need yet
|
| 152 |
+
if into < 0 or need <= 0:
|
| 153 |
+
base = 500
|
| 154 |
+
level = max(1, xp // base + 1)
|
| 155 |
+
start = (level - 1) * base
|
| 156 |
+
into = xp - start
|
| 157 |
+
need = base
|
| 158 |
+
if into == need:
|
| 159 |
+
level += 1
|
| 160 |
+
into = 0
|
| 161 |
+
|
| 162 |
+
cap = max(500, ((xp // 500) + 1) * 500) # next threshold
|
| 163 |
+
pct = 0 if cap <= 0 else min(100, int(round(100 * xp / cap)))
|
| 164 |
+
|
| 165 |
+
st.markdown(
|
| 166 |
+
f"""
|
| 167 |
+
<div class="xp-card">
|
| 168 |
+
<span class="xp-level">Level {level}</span>
|
| 169 |
+
<span class="xp-text">{xp:,} / {cap:,} XP</span>
|
| 170 |
+
<div class="xp-bar">
|
| 171 |
+
<div class="xp-fill" style="width: {pct}%;"></div>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="xp-total">Total XP: {xp:,}</div>
|
| 174 |
+
</div>
|
| 175 |
+
""",
|
| 176 |
+
unsafe_allow_html=True
|
| 177 |
+
)
|
| 178 |
+
# pct = 0 if max_xp <= 0 else min(100, int(round((xp / max_xp) * 100)))
|
| 179 |
+
# st.markdown(
|
| 180 |
+
# f"""
|
| 181 |
+
# <div class="xp-card">
|
| 182 |
+
# <span class="xp-level">Level {level}</span>
|
| 183 |
+
# <span class="xp-text">{xp} / {max_xp} XP</span>
|
| 184 |
+
# <div class="xp-bar">
|
| 185 |
+
# <div class="xp-fill" style="width: {pct}%;"></div>
|
| 186 |
+
# </div>
|
| 187 |
+
# </div>
|
| 188 |
+
# """,
|
| 189 |
+
# unsafe_allow_html=True
|
| 190 |
+
# )
|
| 191 |
+
# st.write("")
|
| 192 |
+
|
| 193 |
+
# --- My Assignments (from DB) ---
|
| 194 |
+
st.markdown("---")
|
| 195 |
+
st.subheader("📘 My Work")
|
| 196 |
+
if not rows:
|
| 197 |
+
st.info("No assignments yet. Ask your teacher to assign a lesson.")
|
| 198 |
+
else:
|
| 199 |
+
for a in rows:
|
| 200 |
+
title = a.get("title", "Untitled")
|
| 201 |
+
subj = a.get("subject", "General")
|
| 202 |
+
lvl = a.get("level", "Beginner")
|
| 203 |
+
status = a.get("status", "not_started")
|
| 204 |
+
due = a.get("due_at")
|
| 205 |
+
due_txt = f" · Due {str(due)[:10]}" if due else ""
|
| 206 |
+
st.markdown(f"**{title}** · {subj} · {lvl}{due_txt}")
|
| 207 |
+
st.caption(f"Status: {status} · Resume at section {a.get('current_pos', 1)}")
|
| 208 |
+
st.markdown("---")
|
| 209 |
+
|
| 210 |
+
# --- Recent Lessons & Achievements ---
|
| 211 |
+
col1, col2 = st.columns(2)
|
| 212 |
+
|
| 213 |
+
def _progress_value(v):
|
| 214 |
+
try:
|
| 215 |
+
f = float(v)
|
| 216 |
+
except Exception:
|
| 217 |
+
return 0.0
|
| 218 |
+
# streamlit accepts 0–1 float; if someone passes 0–100, scale it
|
| 219 |
+
return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
|
| 220 |
+
|
| 221 |
+
with col1:
|
| 222 |
+
st.subheader("📖 Recent Lessons")
|
| 223 |
+
st.caption("Continue where you left off")
|
| 224 |
+
if not recent_lessons:
|
| 225 |
+
st.info("No recent lessons yet.")
|
| 226 |
+
else:
|
| 227 |
+
for lesson in recent_lessons:
|
| 228 |
+
prog = lesson.get("progress", 0)
|
| 229 |
+
st.progress(_progress_value(prog))
|
| 230 |
+
status = "✅ Complete" if (isinstance(prog, (int, float)) and prog >= 100) else f"{int(prog)}% complete"
|
| 231 |
+
st.write(f"**{lesson.get('title','Untitled Lesson')}** — {status}")
|
| 232 |
+
|
| 233 |
+
with col2:
|
| 234 |
+
st.subheader("🏆 Achievements")
|
| 235 |
+
st.caption("Your learning milestones")
|
| 236 |
+
for ach in achievements:
|
| 237 |
+
if ach["earned"]:
|
| 238 |
+
st.success(f"✔ {ach['title']} — {ach['desc']}")
|
| 239 |
+
else:
|
| 240 |
+
st.info(f"🔒 {ach['title']} — {ach['desc']}")
|
dashboards/teacher_db.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dashboards/teacher_db.py
|
| 2 |
+
import os
|
| 3 |
+
import io
|
| 4 |
+
import csv
|
| 5 |
+
import datetime
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import plotly.graph_objects as go
|
| 8 |
+
|
| 9 |
+
from utils import db as dbapi
|
| 10 |
+
import utils.api as api # backend Space client
|
| 11 |
+
|
| 12 |
+
# If DISABLE_DB=1 (default), don't call MySQL at all
|
| 13 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 14 |
+
|
| 15 |
+
def load_css(file_name):
|
| 16 |
+
try:
|
| 17 |
+
with open(file_name, 'r', encoding="utf-8") as f:
|
| 18 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 19 |
+
except FileNotFoundError:
|
| 20 |
+
st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
|
| 21 |
+
|
| 22 |
+
def tile(icon, label, value):
|
| 23 |
+
return f"""
|
| 24 |
+
<div class="metric-card">
|
| 25 |
+
<div class="metric-icon">{icon}</div>
|
| 26 |
+
<div class="metric-value">{value}</div>
|
| 27 |
+
<div class="metric-label">{label}</div>
|
| 28 |
+
</div>
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
def _level_from_xp(xp: int) -> int:
|
| 32 |
+
"""
|
| 33 |
+
Prefer backend/db helper if available, else simple fallback (every 500 XP = +1 level).
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
if USE_LOCAL_DB and hasattr(dbapi, "level_from_xp"):
|
| 37 |
+
return int(dbapi.level_from_xp(xp))
|
| 38 |
+
if hasattr(api, "level_from_xp"): # if you add the endpoint
|
| 39 |
+
return int(api.level_from_xp(int(xp)))
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
xp = int(xp or 0)
|
| 43 |
+
return 1 + (xp // 500)
|
| 44 |
+
|
| 45 |
+
def _safe_get_tiles(teacher_id: int) -> dict:
|
| 46 |
+
if USE_LOCAL_DB and hasattr(dbapi, "teacher_tiles"):
|
| 47 |
+
return dbapi.teacher_tiles(teacher_id)
|
| 48 |
+
try:
|
| 49 |
+
return api.teacher_tiles(teacher_id)
|
| 50 |
+
except Exception:
|
| 51 |
+
return {
|
| 52 |
+
"total_students": 0, "class_avg": 0.0,
|
| 53 |
+
"lessons_created": 0, "active_students": 0
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
def _safe_list_classes(teacher_id: int) -> list:
|
| 57 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"):
|
| 58 |
+
return dbapi.list_classes_by_teacher(teacher_id)
|
| 59 |
+
try:
|
| 60 |
+
return api.list_classes_by_teacher(teacher_id)
|
| 61 |
+
except Exception:
|
| 62 |
+
return []
|
| 63 |
+
|
| 64 |
+
def _safe_create_class(teacher_id: int, name: str) -> dict:
|
| 65 |
+
if USE_LOCAL_DB and hasattr(dbapi, "create_class"):
|
| 66 |
+
return dbapi.create_class(teacher_id, name)
|
| 67 |
+
return api.create_class(teacher_id, name)
|
| 68 |
+
|
| 69 |
+
def _safe_class_student_metrics(class_id: int) -> list:
|
| 70 |
+
if USE_LOCAL_DB and hasattr(dbapi, "class_student_metrics"):
|
| 71 |
+
return dbapi.class_student_metrics(class_id)
|
| 72 |
+
try:
|
| 73 |
+
return api.class_student_metrics(class_id)
|
| 74 |
+
except Exception:
|
| 75 |
+
return []
|
| 76 |
+
|
| 77 |
+
def _safe_weekly_activity(class_id: int) -> list:
|
| 78 |
+
if USE_LOCAL_DB and hasattr(dbapi, "class_weekly_activity"):
|
| 79 |
+
return dbapi.class_weekly_activity(class_id)
|
| 80 |
+
try:
|
| 81 |
+
return api.class_weekly_activity(class_id)
|
| 82 |
+
except Exception:
|
| 83 |
+
return []
|
| 84 |
+
|
| 85 |
+
def _safe_progress_overview(class_id: int) -> dict:
|
| 86 |
+
if USE_LOCAL_DB and hasattr(dbapi, "class_progress_overview"):
|
| 87 |
+
return dbapi.class_progress_overview(class_id)
|
| 88 |
+
try:
|
| 89 |
+
return api.class_progress_overview(class_id)
|
| 90 |
+
except Exception:
|
| 91 |
+
return {
|
| 92 |
+
"overall_progress": 0.0, "quiz_performance": 0.0,
|
| 93 |
+
"lessons_completed": 0, "class_xp": 0
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
def _safe_recent_activity(class_id: int, limit=6, days=30) -> list:
|
| 97 |
+
if USE_LOCAL_DB and hasattr(dbapi, "class_recent_activity"):
|
| 98 |
+
return dbapi.class_recent_activity(class_id, limit=limit, days=days)
|
| 99 |
+
try:
|
| 100 |
+
return api.class_recent_activity(class_id, limit=limit, days=days)
|
| 101 |
+
except Exception:
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
def _safe_list_students(class_id: int) -> list:
|
| 105 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_students_in_class"):
|
| 106 |
+
return dbapi.list_students_in_class(class_id)
|
| 107 |
+
try:
|
| 108 |
+
return api.list_students_in_class(class_id)
|
| 109 |
+
except Exception:
|
| 110 |
+
return []
|
| 111 |
+
|
| 112 |
+
def show_teacher_dashboard():
|
| 113 |
+
css_path = os.path.join("assets", "styles.css")
|
| 114 |
+
load_css(css_path)
|
| 115 |
+
|
| 116 |
+
user = st.session_state.user
|
| 117 |
+
teacher_id = user["user_id"]
|
| 118 |
+
name = user["name"]
|
| 119 |
+
|
| 120 |
+
# ========== HEADER / HERO ==========
|
| 121 |
+
colH1, colH2 = st.columns([5, 2])
|
| 122 |
+
with colH1:
|
| 123 |
+
st.markdown(f"""
|
| 124 |
+
<div class="header-container">
|
| 125 |
+
<div class="header-content">
|
| 126 |
+
<div class="header-left">
|
| 127 |
+
<div class="header-title">Welcome back, Teacher {name}!</div>
|
| 128 |
+
<div class="header-subtitle">Managing your classrooms</div>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
""", unsafe_allow_html=True)
|
| 133 |
+
with colH2:
|
| 134 |
+
with st.popover("➕ Create Classroom"):
|
| 135 |
+
new_class_name = st.text_input("Classroom Name", key="new_class_name")
|
| 136 |
+
if st.button("Create Classroom", key="create_classroom_btn"):
|
| 137 |
+
if new_class_name.strip():
|
| 138 |
+
try:
|
| 139 |
+
out = _safe_create_class(teacher_id, new_class_name.strip())
|
| 140 |
+
code = out.get("code") or out.get("class_code") or "—"
|
| 141 |
+
st.success(f"Classroom created. Code: **{code}**")
|
| 142 |
+
except Exception as e:
|
| 143 |
+
st.error(f"Create failed: {e}")
|
| 144 |
+
|
| 145 |
+
# ========== TILES ==========
|
| 146 |
+
tiles = _safe_get_tiles(teacher_id)
|
| 147 |
+
c1,c2,c3,c4 = st.columns(4)
|
| 148 |
+
c1.markdown(tile("👥","Total Students", tiles.get("total_students", 0)), unsafe_allow_html=True)
|
| 149 |
+
c2.markdown(tile("📊","Class Average", f"{int((tiles.get('class_avg') or 0)*100)}%"), unsafe_allow_html=True)
|
| 150 |
+
c3.markdown(tile("📚","Lessons Created", tiles.get("lessons_created", 0)), unsafe_allow_html=True)
|
| 151 |
+
c4.markdown(tile("📈","Active Students", tiles.get("active_students", 0)), unsafe_allow_html=True)
|
| 152 |
+
|
| 153 |
+
# ========== CLASS PICKER ==========
|
| 154 |
+
classes = _safe_list_classes(teacher_id)
|
| 155 |
+
if not classes:
|
| 156 |
+
st.info("No classes yet. Create one above, then share the code with students.")
|
| 157 |
+
return
|
| 158 |
+
|
| 159 |
+
idx = st.selectbox(
|
| 160 |
+
"Choose a class",
|
| 161 |
+
list(range(len(classes))),
|
| 162 |
+
index=0,
|
| 163 |
+
format_func=lambda i: f"{classes[i].get('name','Class')} (Code: {classes[i].get('code','')})"
|
| 164 |
+
)
|
| 165 |
+
selected = classes[idx]
|
| 166 |
+
class_id = selected.get("class_id") or selected.get("id")
|
| 167 |
+
class_code = selected.get("code","")
|
| 168 |
+
|
| 169 |
+
# secondary hero controls
|
| 170 |
+
cTop1, cTop2, cTop3 = st.columns([2,1,1])
|
| 171 |
+
with cTop1:
|
| 172 |
+
st.button(f"Class Code: {class_code}", disabled=True)
|
| 173 |
+
with cTop2:
|
| 174 |
+
if st.button("📋 Copy Code"):
|
| 175 |
+
st.toast("Code copied. Paste it anywhere your heart desires.")
|
| 176 |
+
with cTop3:
|
| 177 |
+
rows = _safe_class_student_metrics(class_id)
|
| 178 |
+
if rows:
|
| 179 |
+
headers = []
|
| 180 |
+
for r in rows:
|
| 181 |
+
for k in r.keys():
|
| 182 |
+
if k not in headers:
|
| 183 |
+
headers.append(k)
|
| 184 |
+
buf = io.StringIO()
|
| 185 |
+
writer = csv.DictWriter(buf, fieldnames=headers)
|
| 186 |
+
writer.writeheader()
|
| 187 |
+
for r in rows:
|
| 188 |
+
writer.writerow(r)
|
| 189 |
+
st.download_button(
|
| 190 |
+
"📤 Export Class Report",
|
| 191 |
+
data=buf.getvalue(),
|
| 192 |
+
file_name=f"class_{class_id}_report.csv",
|
| 193 |
+
mime="text/csv"
|
| 194 |
+
)
|
| 195 |
+
else:
|
| 196 |
+
st.button("📤 Export Class Report", disabled=True)
|
| 197 |
+
|
| 198 |
+
# ========== TOP ROW: WEEKLY ACTIVITY + CLASS PROGRESS ==========
|
| 199 |
+
left, right = st.columns([3,2])
|
| 200 |
+
|
| 201 |
+
with left:
|
| 202 |
+
st.subheader("Weekly Activity")
|
| 203 |
+
st.caption("Student engagement throughout the week")
|
| 204 |
+
activity = _safe_weekly_activity(class_id)
|
| 205 |
+
if activity:
|
| 206 |
+
days, lessons, quizzes, games = [], [], [], []
|
| 207 |
+
for row in activity:
|
| 208 |
+
date_str = row.get("date")
|
| 209 |
+
try:
|
| 210 |
+
# Support ISO date or datetime
|
| 211 |
+
day = datetime.datetime.fromisoformat(str(date_str)).strftime("%a")
|
| 212 |
+
except Exception:
|
| 213 |
+
day = str(date_str)
|
| 214 |
+
days.append(day)
|
| 215 |
+
lessons.append(row.get("lessons",0))
|
| 216 |
+
quizzes.append(row.get("quizzes",0))
|
| 217 |
+
games.append(row.get("games",0))
|
| 218 |
+
fig = go.Figure(data=[
|
| 219 |
+
go.Bar(name="Lessons", x=days, y=lessons),
|
| 220 |
+
go.Bar(name="Quizzes", x=days, y=quizzes),
|
| 221 |
+
go.Bar(name="Games", x=days, y=games),
|
| 222 |
+
])
|
| 223 |
+
fig.update_layout(barmode="group", xaxis_title="Day", yaxis_title="Count")
|
| 224 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 225 |
+
else:
|
| 226 |
+
st.info("No activity in the last 7 days.")
|
| 227 |
+
|
| 228 |
+
with right:
|
| 229 |
+
st.subheader("Class Progress Overview")
|
| 230 |
+
st.caption("How your students are performing")
|
| 231 |
+
|
| 232 |
+
prog = _safe_progress_overview(class_id)
|
| 233 |
+
overall_pct = int(round((prog.get("overall_progress") or 0) * 100))
|
| 234 |
+
quiz_pct = int(round((prog.get("quiz_performance") or 0) * 100))
|
| 235 |
+
|
| 236 |
+
st.text("Overall Progress")
|
| 237 |
+
st.progress(min(1.0, overall_pct/100.0))
|
| 238 |
+
st.caption(f"{overall_pct}%")
|
| 239 |
+
|
| 240 |
+
st.text("Quiz Performance")
|
| 241 |
+
st.progress(min(1.0, quiz_pct/100.0))
|
| 242 |
+
st.caption(f"{quiz_pct}%")
|
| 243 |
+
|
| 244 |
+
k1, k2 = st.columns(2)
|
| 245 |
+
k1.metric("📖 Lessons Completed", prog.get("lessons_completed", 0))
|
| 246 |
+
k2.metric("🪙 Total Class XP", prog.get("class_xp", 0))
|
| 247 |
+
|
| 248 |
+
# ========== BOTTOM ROW: RECENT ACTIVITY + QUICK ACTIONS ==========
|
| 249 |
+
b1, b2 = st.columns([3,2])
|
| 250 |
+
|
| 251 |
+
with b1:
|
| 252 |
+
st.subheader("Recent Student Activity")
|
| 253 |
+
st.caption("Latest activity from your students")
|
| 254 |
+
feed = _safe_recent_activity(class_id, limit=6, days=30)
|
| 255 |
+
if not feed:
|
| 256 |
+
st.caption("Nothing yet. Assign something, chief.")
|
| 257 |
+
else:
|
| 258 |
+
for r in feed:
|
| 259 |
+
kind = str(r.get("kind","")).lower()
|
| 260 |
+
icon = "📘" if kind == "lesson" else "🏆" if kind == "quiz" else "🎮"
|
| 261 |
+
lvl = r.get("level") or _level_from_xp(r.get("total_xp", 0))
|
| 262 |
+
tail = f" · {r['extra']}" if r.get("extra") else ""
|
| 263 |
+
st.write(f"{icon} **{r.get('student_name','(unknown)')}** — {r.get('item_title','(untitled)')}{tail} \n"
|
| 264 |
+
f"*Level {lvl}*")
|
| 265 |
+
|
| 266 |
+
with b2:
|
| 267 |
+
st.subheader("Quick Actions")
|
| 268 |
+
st.caption("Manage your classroom")
|
| 269 |
+
if st.button("📖 Create New Lesson", use_container_width=True):
|
| 270 |
+
st.session_state.current_page = "Content Management"
|
| 271 |
+
st.rerun()
|
| 272 |
+
if st.button("🏆 Create New Quiz", use_container_width=True):
|
| 273 |
+
st.session_state.current_page = "Content Management"
|
| 274 |
+
st.rerun()
|
| 275 |
+
if st.button("🗓️ Schedule Assignment", use_container_width=True):
|
| 276 |
+
st.session_state.current_page = "Class management"
|
| 277 |
+
st.rerun()
|
| 278 |
+
if st.button("📄 Generate Reports", use_container_width=True):
|
| 279 |
+
st.session_state.current_page = "Students List"
|
| 280 |
+
st.rerun()
|
| 281 |
+
|
| 282 |
+
# optional: keep your per-class expanders below
|
| 283 |
+
for c in classes:
|
| 284 |
+
with st.expander(f"{c.get('name','Class')} · Code **{c.get('code','')}**"):
|
| 285 |
+
st.write(f"Students: {c.get('total_students', 0)}")
|
| 286 |
+
avg = c.get("class_avg", 0.0)
|
| 287 |
+
st.write(f"Average score: {round(float(avg)*100) if avg is not None else 0}%")
|
| 288 |
+
roster = _safe_list_students(c.get("class_id") or c.get("id"))
|
| 289 |
+
if roster:
|
| 290 |
+
for s in roster:
|
| 291 |
+
lvl_slug = (s.get("level_slug") or s.get("level") or "beginner")
|
| 292 |
+
st.write(f"- {s.get('name','(unknown)')} · {s.get('email','—')} · Level {str(lvl_slug).capitalize()}")
|
| 293 |
+
else:
|
| 294 |
+
st.caption("No students yet. Share the code.")
|
isrgrootx1.pem
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-----BEGIN CERTIFICATE-----
|
| 2 |
+
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
| 3 |
+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
| 4 |
+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
| 5 |
+
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
| 6 |
+
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
| 7 |
+
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
| 8 |
+
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
| 9 |
+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
| 10 |
+
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
| 11 |
+
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
| 12 |
+
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
| 13 |
+
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
| 14 |
+
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
| 15 |
+
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
| 16 |
+
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
| 17 |
+
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
| 18 |
+
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
| 19 |
+
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
| 20 |
+
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
| 21 |
+
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
| 22 |
+
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
| 23 |
+
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
| 24 |
+
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
| 25 |
+
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
| 26 |
+
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
| 27 |
+
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
| 28 |
+
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
| 29 |
+
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
| 30 |
+
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
| 31 |
+
-----END CERTIFICATE-----
|
phase/Student_view/chatbot.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase/Student_view/chatbot.py
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import datetime, os, traceback
|
| 4 |
+
from huggingface_hub import InferenceClient
|
| 5 |
+
|
| 6 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 7 |
+
GEN_MODEL = os.getenv("GEN_MODEL", "TinyLlama/TinyLlama-1.1B-Chat-v1.0") # <- default TinyLlama
|
| 8 |
+
|
| 9 |
+
if not HF_TOKEN:
|
| 10 |
+
st.error("⚠️ HF_TOKEN is not set. In your Space, add a Secret named HF_TOKEN.")
|
| 11 |
+
else:
|
| 12 |
+
client = InferenceClient(model=GEN_MODEL, token=HF_TOKEN, timeout=60)
|
| 13 |
+
|
| 14 |
+
TUTOR_PROMPT = (
|
| 15 |
+
"You are a kind Jamaican primary-school finance tutor. "
|
| 16 |
+
"Keep answers short, friendly, and age-appropriate. "
|
| 17 |
+
"Teach step-by-step with tiny examples. Avoid giving personal financial advice."
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# -------------------------------
|
| 23 |
+
# History helpers
|
| 24 |
+
# -------------------------------
|
| 25 |
+
def _format_history_for_flan(messages: list[dict]) -> str:
|
| 26 |
+
"""Format history for text-generation style models."""
|
| 27 |
+
lines = []
|
| 28 |
+
for m in messages:
|
| 29 |
+
txt = (m.get("text") or "").strip()
|
| 30 |
+
if not txt:
|
| 31 |
+
continue
|
| 32 |
+
lines.append(("Tutor" if m.get("sender") == "assistant" else "User") + f": {txt}")
|
| 33 |
+
return "\n".join(lines)
|
| 34 |
+
|
| 35 |
+
def _history_as_chat_messages(messages: list[dict]) -> list[dict]:
|
| 36 |
+
"""Convert history to chat-completion style messages."""
|
| 37 |
+
msgs = [{"role": "system", "content": TUTOR_PROMPT}]
|
| 38 |
+
for m in messages:
|
| 39 |
+
txt = (m.get("text") or "").strip()
|
| 40 |
+
if not txt:
|
| 41 |
+
continue
|
| 42 |
+
role = "assistant" if m.get("sender") == "assistant" else "user"
|
| 43 |
+
msgs.append({"role": role, "content": txt})
|
| 44 |
+
return msgs
|
| 45 |
+
|
| 46 |
+
def _extract_chat_text(chat_resp) -> str:
|
| 47 |
+
"""Extract text from HF chat response."""
|
| 48 |
+
try:
|
| 49 |
+
return chat_resp.choices[0].message["content"] if isinstance(
|
| 50 |
+
chat_resp.choices[0].message, dict
|
| 51 |
+
) else chat_resp.choices[0].message.content
|
| 52 |
+
except Exception:
|
| 53 |
+
try:
|
| 54 |
+
return chat_resp["choices"][0]["message"]["content"]
|
| 55 |
+
except Exception:
|
| 56 |
+
return str(chat_resp)
|
| 57 |
+
|
| 58 |
+
# -------------------------------
|
| 59 |
+
# Reply logic
|
| 60 |
+
# -------------------------------
|
| 61 |
+
def _reply_with_hf():
|
| 62 |
+
if "client" not in globals():
|
| 63 |
+
raise RuntimeError("HF client not initialized")
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
# 1) Prefer chat API
|
| 67 |
+
msgs = _history_as_chat_messages(st.session_state.get("messages", []))
|
| 68 |
+
chat = client.chat.completions.create(
|
| 69 |
+
model=GEN_MODEL,
|
| 70 |
+
messages=msgs,
|
| 71 |
+
max_tokens=300, # give enough room
|
| 72 |
+
temperature=0.2,
|
| 73 |
+
top_p=0.9,
|
| 74 |
+
)
|
| 75 |
+
return _extract_chat_text(chat).strip()
|
| 76 |
+
|
| 77 |
+
except ValueError as ve:
|
| 78 |
+
# 2) Fallback to text-generation if chat unsupported
|
| 79 |
+
if "Supported task: text-generation" in str(ve):
|
| 80 |
+
convo = _format_history_for_flan(st.session_state.get("messages", []))
|
| 81 |
+
tg_prompt = f"{TUTOR_PROMPT}\n\n{convo}\n\nTutor:"
|
| 82 |
+
resp = client.text_generation(
|
| 83 |
+
tg_prompt,
|
| 84 |
+
max_new_tokens=300,
|
| 85 |
+
temperature=0.2,
|
| 86 |
+
top_p=0.9,
|
| 87 |
+
repetition_penalty=1.1,
|
| 88 |
+
return_full_text=True,
|
| 89 |
+
stream=False,
|
| 90 |
+
)
|
| 91 |
+
return (resp.get("generated_text") if isinstance(resp, dict) else resp).strip()
|
| 92 |
+
|
| 93 |
+
raise # rethrow anything else
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
err_text = ''.join(traceback.format_exception_only(type(e), e)).strip()
|
| 97 |
+
raise RuntimeError(f"Hugging Face API Error: {err_text}")
|
| 98 |
+
|
| 99 |
+
# -------------------------------
|
| 100 |
+
# Session message helper
|
| 101 |
+
# -------------------------------
|
| 102 |
+
def add_message(text: str, sender: str):
|
| 103 |
+
if "messages" not in st.session_state:
|
| 104 |
+
st.session_state.messages = []
|
| 105 |
+
st.session_state.messages.append(
|
| 106 |
+
{
|
| 107 |
+
"id": str(datetime.datetime.now().timestamp()),
|
| 108 |
+
"text": text,
|
| 109 |
+
"sender": sender,
|
| 110 |
+
"timestamp": datetime.datetime.now()
|
| 111 |
+
}
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
def _coerce_ts(ts):
|
| 115 |
+
if isinstance(ts, datetime.datetime):
|
| 116 |
+
return ts
|
| 117 |
+
if isinstance(ts, (int, float)):
|
| 118 |
+
try:
|
| 119 |
+
return datetime.datetime.fromtimestamp(ts)
|
| 120 |
+
except Exception:
|
| 121 |
+
return None
|
| 122 |
+
if isinstance(ts, str):
|
| 123 |
+
# Try ISO 8601 first; fall back to float epoch
|
| 124 |
+
try:
|
| 125 |
+
return datetime.datetime.fromisoformat(ts)
|
| 126 |
+
except Exception:
|
| 127 |
+
try:
|
| 128 |
+
return datetime.datetime.fromtimestamp(float(ts))
|
| 129 |
+
except Exception:
|
| 130 |
+
return None
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
def _normalize_messages():
|
| 134 |
+
msgs = st.session_state.get("messages", [])
|
| 135 |
+
normed = []
|
| 136 |
+
now = datetime.datetime.now()
|
| 137 |
+
for m in msgs:
|
| 138 |
+
text = (m.get("text") or "").strip()
|
| 139 |
+
sender = m.get("sender") or "user"
|
| 140 |
+
ts = _coerce_ts(m.get("timestamp")) or now
|
| 141 |
+
normed.append({**m, "text": text, "sender": sender, "timestamp": ts})
|
| 142 |
+
st.session_state.messages = normed
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# -------------------------------
|
| 146 |
+
# Streamlit page
|
| 147 |
+
# -------------------------------
|
| 148 |
+
def show_page():
|
| 149 |
+
st.title("🤖 AI Financial Tutor")
|
| 150 |
+
st.caption("Get personalized help with your financial questions")
|
| 151 |
+
|
| 152 |
+
if "messages" not in st.session_state:
|
| 153 |
+
st.session_state.messages = [{
|
| 154 |
+
"id": "1",
|
| 155 |
+
"text": "Hi! I'm your AI Financial Tutor. What would you like to learn today?",
|
| 156 |
+
"sender": "assistant",
|
| 157 |
+
"timestamp": datetime.datetime.now()
|
| 158 |
+
}]
|
| 159 |
+
if "is_typing" not in st.session_state:
|
| 160 |
+
st.session_state.is_typing = False
|
| 161 |
+
|
| 162 |
+
_normalize_messages()
|
| 163 |
+
|
| 164 |
+
chat_container = st.container()
|
| 165 |
+
with chat_container:
|
| 166 |
+
for msg in st.session_state.messages:
|
| 167 |
+
time_str = msg["timestamp"].strftime("%H:%M") if hasattr(msg["timestamp"], "strftime") else datetime.datetime.now().strftime("%H:%M")
|
| 168 |
+
bubble = (
|
| 169 |
+
f"<div style='background-color:#e0e0e0; color:black; padding:10px; border-radius:12px; max-width:70%; margin-bottom:5px;'>"
|
| 170 |
+
f"{msg.get('text','')}<br><sub>{time_str}</sub></div>"
|
| 171 |
+
if msg.get("sender") == "assistant" else
|
| 172 |
+
f"<div style='background-color:#4CAF50; color:white; padding:10px; border-radius:12px; max-width:70%; margin-left:auto; margin-bottom:5px;'>"
|
| 173 |
+
f"{msg.get('text','')}<br><sub>{time_str}</sub></div>"
|
| 174 |
+
)
|
| 175 |
+
st.markdown(bubble, unsafe_allow_html=True)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
if st.session_state.is_typing:
|
| 179 |
+
st.markdown("🤖 _FinanceBot is typing..._")
|
| 180 |
+
|
| 181 |
+
if len(st.session_state.messages) == 1:
|
| 182 |
+
st.markdown("Try asking about:")
|
| 183 |
+
cols = st.columns(2)
|
| 184 |
+
quick = [
|
| 185 |
+
"How does compound interest work?",
|
| 186 |
+
"How much should I save for emergencies?",
|
| 187 |
+
"What's a good budgeting strategy?",
|
| 188 |
+
"How do I start investing?"
|
| 189 |
+
]
|
| 190 |
+
for i, q in enumerate(quick):
|
| 191 |
+
if cols[i % 2].button(q):
|
| 192 |
+
add_message(q, "user")
|
| 193 |
+
st.session_state.is_typing = True
|
| 194 |
+
st.rerun()
|
| 195 |
+
|
| 196 |
+
user_input = st.chat_input("Ask me anything about personal finance...")
|
| 197 |
+
if user_input:
|
| 198 |
+
add_message(user_input, "user")
|
| 199 |
+
st.session_state.is_typing = True
|
| 200 |
+
st.rerun()
|
| 201 |
+
|
| 202 |
+
if st.session_state.is_typing:
|
| 203 |
+
try:
|
| 204 |
+
with st.spinner("FinanceBot is thinking..."):
|
| 205 |
+
bot_reply = _reply_with_hf()
|
| 206 |
+
add_message(bot_reply, "assistant")
|
| 207 |
+
except Exception as e:
|
| 208 |
+
add_message(f"⚠️ Error: {e}", "assistant")
|
| 209 |
+
finally:
|
| 210 |
+
st.session_state.is_typing = False
|
| 211 |
+
st.rerun()
|
| 212 |
+
|
| 213 |
+
if st.button("Back to Dashboard", key="ai_tutor_back_btn"):
|
| 214 |
+
st.session_state.current_page = "Student Dashboard"
|
| 215 |
+
st.rerun()
|
phase/Student_view/game.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from utils import db as dbapi
|
| 3 |
+
import os
|
| 4 |
+
import utils.api as api
|
| 5 |
+
|
| 6 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 7 |
+
|
| 8 |
+
# --- Load external CSS (optional) ---
|
| 9 |
+
def load_css(file_name: str):
|
| 10 |
+
try:
|
| 11 |
+
with open(file_name, "r", encoding="utf-8") as f:
|
| 12 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 13 |
+
except FileNotFoundError:
|
| 14 |
+
st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
st.session_state.setdefault("current_game", None)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# --- GAME RENDERERS ---
|
| 21 |
+
def _render_budget_builder():
|
| 22 |
+
try:
|
| 23 |
+
from phase.Student_view.games import budgetbuilder as budget_module
|
| 24 |
+
except Exception as e:
|
| 25 |
+
st.error(f"Couldn't import Budget Builder module: {e}")
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
if hasattr(budget_module, "show_budget_builder"):
|
| 29 |
+
budget_module.show_budget_builder()
|
| 30 |
+
elif hasattr(budget_module, "show_page"):
|
| 31 |
+
budget_module.show_page()
|
| 32 |
+
else:
|
| 33 |
+
st.error("Budget Builder module found, but no show function (show_budget_builder/show_page).")
|
| 34 |
+
|
| 35 |
+
def _render_debt_dilemma():
|
| 36 |
+
try:
|
| 37 |
+
from phase.Student_view.games import debtdilemma as debt_module
|
| 38 |
+
except Exception as e:
|
| 39 |
+
st.error(f"Couldn't import Debt Dilemma module: {e}")
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
if hasattr(debt_module, "show_debt_dilemma"):
|
| 43 |
+
debt_module.show_debt_dilemma()
|
| 44 |
+
elif hasattr(debt_module, "show_page"):
|
| 45 |
+
debt_module.show_page()
|
| 46 |
+
else:
|
| 47 |
+
st.error("Debt Dilemma module found, but no show function (show_debt_dilemma/show_page).")
|
| 48 |
+
|
| 49 |
+
def _render_money_match():
|
| 50 |
+
"""
|
| 51 |
+
Renders Money Match if the file exists at phase/games/MoneyMatch.py
|
| 52 |
+
and exposes a show_page() function.
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
|
| 56 |
+
from phase.Student_view.games import MoneyMatch as mm_module
|
| 57 |
+
except Exception as e:
|
| 58 |
+
st.error(f"Couldn't import Money Match module: {e}")
|
| 59 |
+
st.info("Tip: ensure phase/games/MoneyMatch.py exists and defines show_page()")
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
if hasattr(mm_module, "show_page"):
|
| 63 |
+
mm_module.show_page()
|
| 64 |
+
else:
|
| 65 |
+
st.error("Money Match module found, but no show_page() function.")
|
| 66 |
+
|
| 67 |
+
#render for profit puzzle
|
| 68 |
+
def _render_profit_puzzle():
|
| 69 |
+
try:
|
| 70 |
+
from phase.Student_view.games import profitpuzzle as pp_module
|
| 71 |
+
except Exception as e:
|
| 72 |
+
st.error(f"Couldn't import Profit Puzzle module: {e}")
|
| 73 |
+
return
|
| 74 |
+
|
| 75 |
+
if hasattr(pp_module, "show_profit_puzzle"):
|
| 76 |
+
pp_module.show_profit_puzzle()
|
| 77 |
+
elif hasattr(pp_module, "show_page"):
|
| 78 |
+
pp_module.show_page()
|
| 79 |
+
else:
|
| 80 |
+
st.error("Profit Puzzle module found, but no show function (show_profit_puzzle/show_page).")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
import textwrap
|
| 84 |
+
|
| 85 |
+
def render_leaderboard(leaderboard):
|
| 86 |
+
def rank_symbol(rank):
|
| 87 |
+
if rank == "You":
|
| 88 |
+
return "🟢"
|
| 89 |
+
if isinstance(rank, int):
|
| 90 |
+
return "🥇" if rank == 1 else "🥈" if rank == 2 else "🥉" if rank == 3 else f"#{rank}"
|
| 91 |
+
return str(rank)
|
| 92 |
+
|
| 93 |
+
def rank_medal_class(rank):
|
| 94 |
+
if isinstance(rank, int) and rank in (1, 2, 3):
|
| 95 |
+
return f"medal-{rank}"
|
| 96 |
+
return ""
|
| 97 |
+
|
| 98 |
+
rows = []
|
| 99 |
+
head = '<div class="lb-head">🏆 Leaderboard</div>'
|
| 100 |
+
for p in leaderboard:
|
| 101 |
+
is_you = p["rank"] == "You"
|
| 102 |
+
medal_cls = rank_medal_class(p["rank"])
|
| 103 |
+
symbol = rank_symbol(p["rank"])
|
| 104 |
+
you_pill = '<span class="lb-you-pill">YOU</span>' if is_you else ""
|
| 105 |
+
rows.append(
|
| 106 |
+
textwrap.dedent(f"""
|
| 107 |
+
<div class="lb-row {'is-you' if is_you else ''}">
|
| 108 |
+
<div class="lb-rank {medal_cls}">{symbol}</div>
|
| 109 |
+
<div class="lb-name">{p['name']}</div>
|
| 110 |
+
<div class="lb-level">Lvl {p['level']}</div>
|
| 111 |
+
<div class="lb-xp">{p['xp']:,} XP</div>
|
| 112 |
+
{you_pill}
|
| 113 |
+
</div>
|
| 114 |
+
""").strip()
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
html = textwrap.dedent(f"""
|
| 118 |
+
<div class="leaderboard">
|
| 119 |
+
{head}
|
| 120 |
+
{''.join(rows)}
|
| 121 |
+
</div>
|
| 122 |
+
""").strip()
|
| 123 |
+
|
| 124 |
+
st.markdown(html, unsafe_allow_html=True)
|
| 125 |
+
|
| 126 |
+
def _load_leaderboard(user_id: int, limit: int = 10) -> list[dict]:
|
| 127 |
+
you_name = (st.session_state.get("user") or {}).get("name") or "You"
|
| 128 |
+
class_id = st.session_state.get("current_class_id")
|
| 129 |
+
rows: list[dict] = []
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
if USE_LOCAL_DB:
|
| 133 |
+
# ---------- local DB path ----------
|
| 134 |
+
if not class_id and hasattr(dbapi, "list_classes_for_student"):
|
| 135 |
+
classes = dbapi.list_classes_for_student(user_id) or []
|
| 136 |
+
if classes:
|
| 137 |
+
class_id = classes[0]["class_id"]
|
| 138 |
+
st.session_state.current_class_id = class_id
|
| 139 |
+
|
| 140 |
+
if class_id and hasattr(dbapi, "leaderboard_for_class"):
|
| 141 |
+
rows = dbapi.leaderboard_for_class(class_id, limit=limit) or []
|
| 142 |
+
elif hasattr(dbapi, "leaderboard_global"):
|
| 143 |
+
rows = dbapi.leaderboard_global(limit=limit) or []
|
| 144 |
+
elif class_id and hasattr(dbapi, "class_student_metrics"):
|
| 145 |
+
metrics = dbapi.class_student_metrics(class_id) or []
|
| 146 |
+
rows = [{
|
| 147 |
+
"user_id": m.get("student_id"),
|
| 148 |
+
"name": m.get("name") or m.get("email") or "Student",
|
| 149 |
+
"xp": int(m.get("total_xp", 0)),
|
| 150 |
+
"level": dbapi.level_from_xp(int(m.get("total_xp", 0))),
|
| 151 |
+
} for m in metrics]
|
| 152 |
+
|
| 153 |
+
else:
|
| 154 |
+
# ---------- backend API path (DISABLE_DB=1) ----------
|
| 155 |
+
# 1) pick a class for the logged-in student
|
| 156 |
+
if not class_id:
|
| 157 |
+
try:
|
| 158 |
+
classes = api.list_classes_for_student(user_id) or []
|
| 159 |
+
except Exception:
|
| 160 |
+
classes = []
|
| 161 |
+
if classes:
|
| 162 |
+
class_id = classes[0].get("class_id")
|
| 163 |
+
st.session_state.current_class_id = class_id
|
| 164 |
+
|
| 165 |
+
if class_id:
|
| 166 |
+
# 2) get roster
|
| 167 |
+
try:
|
| 168 |
+
roster = api.list_students_in_class(class_id) or []
|
| 169 |
+
except Exception:
|
| 170 |
+
roster = []
|
| 171 |
+
|
| 172 |
+
# 3) for each student, pull stats (XP/level)
|
| 173 |
+
rows = []
|
| 174 |
+
for s in roster:
|
| 175 |
+
sid = s.get("user_id") or s.get("student_id")
|
| 176 |
+
if not sid:
|
| 177 |
+
continue
|
| 178 |
+
try:
|
| 179 |
+
stt = api.user_stats(int(sid)) or {}
|
| 180 |
+
except Exception:
|
| 181 |
+
stt = {}
|
| 182 |
+
rows.append({
|
| 183 |
+
"user_id": int(sid),
|
| 184 |
+
"name": s.get("name") or s.get("email") or "Student",
|
| 185 |
+
"xp": int(stt.get("xp", 0)),
|
| 186 |
+
"level": int(stt.get("level", 1)),
|
| 187 |
+
})
|
| 188 |
+
else:
|
| 189 |
+
# No class available; at least show the current user
|
| 190 |
+
try:
|
| 191 |
+
s = api.user_stats(user_id) or {}
|
| 192 |
+
except Exception:
|
| 193 |
+
s = {}
|
| 194 |
+
rows = [{"user_id": user_id, "name": you_name,
|
| 195 |
+
"xp": int(s.get("xp", 0)), "level": int(s.get("level", 1))}]
|
| 196 |
+
except Exception:
|
| 197 |
+
rows = []
|
| 198 |
+
|
| 199 |
+
# Ensure YOU is present
|
| 200 |
+
if not any(r.get("user_id") == user_id for r in rows):
|
| 201 |
+
rows.append({"user_id": user_id, "name": you_name, "xp": 0, "level": 1})
|
| 202 |
+
|
| 203 |
+
# Rank, mark YOU, put YOU first
|
| 204 |
+
rows.sort(key=lambda r: int(r.get("xp", 0)), reverse=True)
|
| 205 |
+
ranked = []
|
| 206 |
+
for i, r in enumerate(rows, start=1):
|
| 207 |
+
ranked.append({
|
| 208 |
+
"rank": i,
|
| 209 |
+
"user_id": r["user_id"],
|
| 210 |
+
"name": r["name"],
|
| 211 |
+
"level": int(r["level"]),
|
| 212 |
+
"xp": int(r["xp"]),
|
| 213 |
+
})
|
| 214 |
+
for r in ranked:
|
| 215 |
+
if r["user_id"] == user_id:
|
| 216 |
+
r["rank"] = "You"
|
| 217 |
+
break
|
| 218 |
+
you = [r for r in ranked if r["rank"] == "You"]
|
| 219 |
+
others = [r for r in ranked if r["rank"] != "You"]
|
| 220 |
+
return (you + others)[:limit]
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# --- MAIN GAMES HUB & ROUTER ---
|
| 226 |
+
def show_games():
|
| 227 |
+
load_css(os.path.join("assets", "styles.css"))
|
| 228 |
+
|
| 229 |
+
if "user" not in st.session_state or st.session_state.user is None:
|
| 230 |
+
st.error("❌ Please login first.")
|
| 231 |
+
st.session_state.current_page = "Welcome"
|
| 232 |
+
st.rerun()
|
| 233 |
+
|
| 234 |
+
game_key = st.session_state.current_game
|
| 235 |
+
|
| 236 |
+
# If a specific game is active → render it
|
| 237 |
+
if game_key is not None:
|
| 238 |
+
if game_key == "budget_builder":
|
| 239 |
+
_render_budget_builder()
|
| 240 |
+
elif game_key == "money_match":
|
| 241 |
+
_render_money_match()
|
| 242 |
+
elif game_key == "debt_dilemma":
|
| 243 |
+
_render_debt_dilemma()
|
| 244 |
+
elif game_key == "profit_puzzle":
|
| 245 |
+
_render_profit_puzzle()
|
| 246 |
+
|
| 247 |
+
st.markdown("---")
|
| 248 |
+
if st.button("⬅ Back to Games Hub"):
|
| 249 |
+
st.session_state.current_game = None
|
| 250 |
+
st.rerun()
|
| 251 |
+
return # don’t render the hub
|
| 252 |
+
|
| 253 |
+
# ===== Games Hub =====
|
| 254 |
+
st.title("Financial Games")
|
| 255 |
+
st.subheader("Learn by playing! Master financial concepts through interactive games.")
|
| 256 |
+
|
| 257 |
+
# Progress overview
|
| 258 |
+
col1, col2 = st.columns([1, 5])
|
| 259 |
+
with col1:
|
| 260 |
+
st.markdown(
|
| 261 |
+
"""
|
| 262 |
+
<div style="
|
| 263 |
+
width:50px; height:50px;
|
| 264 |
+
border-radius:15px;
|
| 265 |
+
background: linear-gradient(135deg, #22c55e, #059669);
|
| 266 |
+
display:flex; align-items:center; justify-content:center;
|
| 267 |
+
font-size:28px;
|
| 268 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 269 |
+
">
|
| 270 |
+
✨
|
| 271 |
+
</div>
|
| 272 |
+
""",
|
| 273 |
+
unsafe_allow_html=True
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
with col2:
|
| 277 |
+
# pull live XP/level
|
| 278 |
+
user_id = st.session_state.user["user_id"]
|
| 279 |
+
|
| 280 |
+
# Prefer local DB only if enabled. Otherwise call backend.
|
| 281 |
+
if USE_LOCAL_DB and hasattr(dbapi, "user_xp_and_level"):
|
| 282 |
+
stats = dbapi.user_xp_and_level(user_id) # {'xp', 'level', 'streak', maybe 'into','need'}
|
| 283 |
+
else:
|
| 284 |
+
try:
|
| 285 |
+
stats = api.user_stats(user_id) # backend /students/{id}/stats
|
| 286 |
+
except Exception as e:
|
| 287 |
+
# hard fallback so the page still renders
|
| 288 |
+
stats = {"xp": int(st.session_state.get("xp", 0)), "level": 1, "streak": 0}
|
| 289 |
+
|
| 290 |
+
total_xp = int(stats.get("xp", 0))
|
| 291 |
+
level = int(stats.get("level", 1))
|
| 292 |
+
st.session_state.xp = total_xp
|
| 293 |
+
st.session_state.streak = int(stats.get("streak", 0))
|
| 294 |
+
|
| 295 |
+
# Show progress as TOTAL XP toward the NEXT threshold
|
| 296 |
+
base = 500
|
| 297 |
+
# keep the server's level if it is sane, otherwise recompute
|
| 298 |
+
level = level if level >= 1 else max(1, total_xp // base + 1)
|
| 299 |
+
|
| 300 |
+
cap = level * base # Level 1 -> 500, Level 2 -> 1000, etc.
|
| 301 |
+
progress_pct = min(100, int(round((total_xp / cap) * 100)))
|
| 302 |
+
|
| 303 |
+
st.write(f"Level {level} Experience Points")
|
| 304 |
+
st.markdown(f"""
|
| 305 |
+
<div style="background:#e0e0e0;border-radius:12px;padding:3px;width:100%;">
|
| 306 |
+
<div style="
|
| 307 |
+
width:{progress_pct}%;
|
| 308 |
+
background:linear-gradient(135deg,#22c55e,#059669);
|
| 309 |
+
height:24px;border-radius:10px;text-align:right;
|
| 310 |
+
color:white;font-weight:bold;padding-right:8px;line-height:24px;">
|
| 311 |
+
{total_xp:,} / {cap:,} XP
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
<div style="font-size:12px;color:#6b7280;margin-top:6px;">Total XP: {total_xp:,}</div>
|
| 315 |
+
""", unsafe_allow_html=True)
|
| 316 |
+
|
| 317 |
+
st.markdown("---")
|
| 318 |
+
|
| 319 |
+
# Game list
|
| 320 |
+
games = [
|
| 321 |
+
{"key": "money_match", "icon": "💰", "title": "Money Match",
|
| 322 |
+
"description": "Drag coins and notes to match target values. Perfect for learning denominations!",
|
| 323 |
+
"difficulty": "Easy", "xp": "+10 XP", "time": "5-10 min", "color": "linear-gradient(135deg, #22c55e, #059669)"},
|
| 324 |
+
{"key": "budget_builder", "icon": "📊", "title": "Budget Builder",
|
| 325 |
+
"description": "Allocate your weekly allowance across different spending categories with real-time pie charts.",
|
| 326 |
+
"difficulty": "Easy", "xp": "+50 XP", "time": "10-15 min", "color": "linear-gradient(135deg, #3b82f6, #06b6d4)"},
|
| 327 |
+
{"key": "profit_puzzle", "icon": "🧩", "title": "Profit Puzzle",
|
| 328 |
+
"description": "Solve business scenarios with interactive sliders. Calculate profit, revenue, and costs!",
|
| 329 |
+
"difficulty": "Medium", "xp": "+150 XP", "time": "10-15 min", "color": "linear-gradient(135deg, #6366f1, #8b5cf6)"},
|
| 330 |
+
{"key": "debt_dilemma", "icon": "⚠️", "title": "Debt Dilemma",
|
| 331 |
+
"description": "Experience borrowing JA$100 and learn about interest, repayment schedules, and credit scores.",
|
| 332 |
+
"difficulty": "Hard", "xp": "+200 XP", "time": "20-25 min", "color": "linear-gradient(135deg, #f97316, #dc2626)"},
|
| 333 |
+
]
|
| 334 |
+
|
| 335 |
+
cols = st.columns(2)
|
| 336 |
+
color_map = {"Easy": "green", "Medium": "orange", "Hard": "red"}
|
| 337 |
+
|
| 338 |
+
for i, g in enumerate(games):
|
| 339 |
+
with cols[i % 2]:
|
| 340 |
+
st.markdown(
|
| 341 |
+
f"""
|
| 342 |
+
<div style="
|
| 343 |
+
width:60px; height:60px;
|
| 344 |
+
border-radius:16px;
|
| 345 |
+
background:{g['color']};
|
| 346 |
+
display:flex; align-items:center; justify-content:center;
|
| 347 |
+
font-size:28px; margin-bottom:10px;
|
| 348 |
+
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
| 349 |
+
">
|
| 350 |
+
{g['icon']}
|
| 351 |
+
</div>
|
| 352 |
+
""",
|
| 353 |
+
unsafe_allow_html=True
|
| 354 |
+
)
|
| 355 |
+
st.subheader(g["title"])
|
| 356 |
+
st.write(g["description"])
|
| 357 |
+
diff_color = color_map.get(g["difficulty"], "gray")
|
| 358 |
+
st.markdown(
|
| 359 |
+
f"<div style='color:{diff_color}; font-weight:bold'>{g['difficulty']}</div> | "
|
| 360 |
+
f"{g['xp']} | {g['time']}",
|
| 361 |
+
unsafe_allow_html=True
|
| 362 |
+
)
|
| 363 |
+
if st.button("▶ Play Now", key=f"play_{g['key']}"):
|
| 364 |
+
st.session_state.current_game = g["key"]
|
| 365 |
+
st.rerun()
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
st.markdown("---")
|
| 369 |
+
|
| 370 |
+
# Leaderboard & Tips
|
| 371 |
+
col_leader, col_tips = st.columns(2)
|
| 372 |
+
with col_leader:
|
| 373 |
+
user_id = st.session_state.user["user_id"]
|
| 374 |
+
lb = _load_leaderboard(user_id, limit=10)
|
| 375 |
+
if lb:
|
| 376 |
+
render_leaderboard(lb)
|
| 377 |
+
else:
|
| 378 |
+
st.info("No leaderboard data yet.")
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
with col_tips:
|
| 384 |
+
st.subheader("Game Tips")
|
| 385 |
+
for tip in [
|
| 386 |
+
"🌟 Start with easier games to build confidence",
|
| 387 |
+
"⏰ Take your time to understand concepts",
|
| 388 |
+
"🏆 Replay games to improve your score",
|
| 389 |
+
"🌍 Apply game lessons to real life",
|
| 390 |
+
]:
|
| 391 |
+
st.markdown(f"- {tip}")
|
phase/Student_view/games/MoneyMatch.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase\Student_view\games\MoneyMatch.py
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import streamlit as st
|
| 8 |
+
from utils import db as dbapi
|
| 9 |
+
import time
|
| 10 |
+
from utils import db as db_util
|
| 11 |
+
from utils import api
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------- paths ----------
|
| 18 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
| 19 |
+
|
| 20 |
+
def _asset(*parts: str) -> str:
|
| 21 |
+
# JMD image path
|
| 22 |
+
return str((PROJECT_ROOT / "assets" / "images" / Path(*parts)).resolve())
|
| 23 |
+
|
| 24 |
+
def _safe_image(path: str, *, caption: str = ""):
|
| 25 |
+
if not os.path.exists(path):
|
| 26 |
+
st.warning(f"Image not found: {Path(path).name}. Button still works.")
|
| 27 |
+
return False
|
| 28 |
+
st.image(path, use_container_width=True, caption=caption)
|
| 29 |
+
return True
|
| 30 |
+
|
| 31 |
+
# ---------- state helpers ----------
|
| 32 |
+
def _init_state():
|
| 33 |
+
ss = st.session_state
|
| 34 |
+
if "mm_level" not in ss: ss.mm_level = 1
|
| 35 |
+
if "mm_xp" not in ss: ss.mm_xp = 0
|
| 36 |
+
if "mm_matches" not in ss: ss.mm_matches = 0
|
| 37 |
+
if "mm_target" not in ss: ss.mm_target = random.randint(7, 10000) # randon goal generator
|
| 38 |
+
if "mm_selected" not in ss: ss.mm_selected = []
|
| 39 |
+
if "mm_total" not in ss: ss.mm_total = 0
|
| 40 |
+
if "mm_start_ts" not in ss: ss.mm_start_ts = time.perf_counter()
|
| 41 |
+
if "mm_saved" not in ss: ss.mm_saved = False
|
| 42 |
+
|
| 43 |
+
def _reset_round(new_target: int | None = None):
|
| 44 |
+
ss = st.session_state
|
| 45 |
+
ss.mm_selected = []
|
| 46 |
+
ss.mm_total = 0
|
| 47 |
+
ss.mm_target = new_target if new_target is not None else random.randint(7, 10000)
|
| 48 |
+
ss.mm_start_ts = time.perf_counter()
|
| 49 |
+
ss.mm_saved = False
|
| 50 |
+
|
| 51 |
+
def _award_xp(gained: int):
|
| 52 |
+
ss = st.session_state
|
| 53 |
+
ss.mm_xp += gained
|
| 54 |
+
ss.mm_matches += 1
|
| 55 |
+
while ss.mm_xp >= ss.mm_level * 100:
|
| 56 |
+
ss.mm_level += 1
|
| 57 |
+
|
| 58 |
+
def _persist_success(gained_xp: int):
|
| 59 |
+
user = st.session_state.get("user") or {}
|
| 60 |
+
user_id = int(user.get("user_id", 0))
|
| 61 |
+
if not user_id:
|
| 62 |
+
st.error("Not saving. No logged-in user_id in session.")
|
| 63 |
+
return
|
| 64 |
+
|
| 65 |
+
payload = dict(
|
| 66 |
+
user_id=user_id,
|
| 67 |
+
target=int(st.session_state.mm_target),
|
| 68 |
+
total=int(st.session_state.mm_total),
|
| 69 |
+
elapsed_ms=int((time.perf_counter() - st.session_state.mm_start_ts) * 1000),
|
| 70 |
+
matched=True,
|
| 71 |
+
gained_xp=int(gained_xp),
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
if USE_LOCAL_DB and hasattr(dbapi, "record_money_match_play"):
|
| 76 |
+
# direct DB mode
|
| 77 |
+
dbapi.record_money_match_play(**payload)
|
| 78 |
+
st.toast(f"Saved to DB +{gained_xp} XP")
|
| 79 |
+
else:
|
| 80 |
+
# backend mode (DISABLE_DB=1)
|
| 81 |
+
api.record_money_match_play(**payload)
|
| 82 |
+
st.toast(f"Saved via backend +{gained_xp} XP")
|
| 83 |
+
st.session_state.mm_saved = True
|
| 84 |
+
except Exception as e:
|
| 85 |
+
st.error(f"Save failed: {e}")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# --- CSS injection (run every render) ---
|
| 89 |
+
def _inject_css():
|
| 90 |
+
css_path = PROJECT_ROOT / "assets" / "styles.css"
|
| 91 |
+
try:
|
| 92 |
+
css = css_path.read_text(encoding="utf-8")
|
| 93 |
+
st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
|
| 94 |
+
except Exception:
|
| 95 |
+
# don't crash the page because of styling
|
| 96 |
+
pass
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ---------- denominations ----------
|
| 100 |
+
DENOMS = [
|
| 101 |
+
("JA$1", 1, _asset("jmd", "jmd_1.jpeg")),
|
| 102 |
+
("JA$5", 5, _asset("jmd", "jmd_5.jpeg")),
|
| 103 |
+
("JA$10", 10, _asset("jmd", "jmd_10.jpeg")),
|
| 104 |
+
("JA$20", 20, _asset("jmd", "jmd_20.jpeg")),
|
| 105 |
+
("JA$50", 50, _asset("jmd", "jmd_50.jpg")),
|
| 106 |
+
("JA$100", 100, _asset("jmd", "jmd_100.jpg")),
|
| 107 |
+
("JA$500", 500, _asset("jmd", "jmd_500.jpg")),
|
| 108 |
+
("JA$1000", 1000, _asset("jmd", "jmd_1000.jpeg")),
|
| 109 |
+
("JA$2000", 2000, _asset("jmd", "jmd_2000.jpeg")),
|
| 110 |
+
("JA$5000", 5000, _asset("jmd", "jmd_5000.jpeg")),
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
# ---------- main ----------
|
| 114 |
+
def show_page():
|
| 115 |
+
_init_state()
|
| 116 |
+
_inject_css() # <- keep this here so it runs on every rerun
|
| 117 |
+
ss = st.session_state
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if st.button("← Back to Games"):
|
| 121 |
+
ss.current_game = None
|
| 122 |
+
st.rerun()
|
| 123 |
+
|
| 124 |
+
st.title("Money Match Challenge")
|
| 125 |
+
|
| 126 |
+
left, right = st.columns([1.75, 1])
|
| 127 |
+
|
| 128 |
+
with left:
|
| 129 |
+
st.markdown('<div class="mm-card">', unsafe_allow_html=True)
|
| 130 |
+
st.markdown(f"<h3>Target: <span class='mm-target'>JA${ss.mm_target}</span></h3>", unsafe_allow_html=True)
|
| 131 |
+
st.markdown(f"<div class='mm-total'>JA${ss.mm_total}</div>", unsafe_allow_html=True)
|
| 132 |
+
|
| 133 |
+
ratio = min(ss.mm_total / ss.mm_target, 1.0) if ss.mm_target else 0
|
| 134 |
+
st.progress(ratio)
|
| 135 |
+
|
| 136 |
+
diff = ss.mm_target - ss.mm_total
|
| 137 |
+
need_text = "Perfect match. Click Next round." if diff == 0 else (f"Need JA${diff} more" if diff > 0 else f"Overshot by JA${abs(diff)}")
|
| 138 |
+
st.caption(need_text)
|
| 139 |
+
|
| 140 |
+
# autosave stats if perfect match
|
| 141 |
+
if diff == 0 and not ss.mm_saved:
|
| 142 |
+
gained = 10
|
| 143 |
+
_persist_success(gained)
|
| 144 |
+
_award_xp(gained)
|
| 145 |
+
ss.mm_saved = True
|
| 146 |
+
|
| 147 |
+
# tray
|
| 148 |
+
if ss.mm_selected:
|
| 149 |
+
chips = " ".join([f"<span class='mm-chip'>${v}</span>" for v in ss.mm_selected])
|
| 150 |
+
st.markdown(f"<div class='mm-tray'>{chips}</div>", unsafe_allow_html=True)
|
| 151 |
+
else:
|
| 152 |
+
st.markdown("<div class='mm-tray mm-empty'>Selected money will appear here</div>", unsafe_allow_html=True)
|
| 153 |
+
|
| 154 |
+
c1, c2 = st.columns([1,1])
|
| 155 |
+
with c1:
|
| 156 |
+
if st.button("⟲ Reset"):
|
| 157 |
+
_reset_round(ss.mm_target)
|
| 158 |
+
st.rerun()
|
| 159 |
+
with c2:
|
| 160 |
+
if ss.mm_total == ss.mm_target:
|
| 161 |
+
if st.button("Next round ▶"):
|
| 162 |
+
gained = 10
|
| 163 |
+
# avoid double insert when autosave
|
| 164 |
+
if not ss.mm_saved:
|
| 165 |
+
_persist_success(gained)
|
| 166 |
+
_award_xp(gained)
|
| 167 |
+
_reset_round()
|
| 168 |
+
st.rerun()
|
| 169 |
+
|
| 170 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
| 171 |
+
|
| 172 |
+
# Money Collection
|
| 173 |
+
st.markdown("<h4>Money Collection</h4>", unsafe_allow_html=True)
|
| 174 |
+
grid_cols = st.columns(4)
|
| 175 |
+
for i, (label, value, img) in enumerate(DENOMS):
|
| 176 |
+
with grid_cols[i % 4]:
|
| 177 |
+
_safe_image(img, caption=label)
|
| 178 |
+
if st.button(label, key=f"mm_add_{value}"):
|
| 179 |
+
ss.mm_selected.append(value)
|
| 180 |
+
ss.mm_total += value
|
| 181 |
+
st.rerun()
|
| 182 |
+
|
| 183 |
+
with right:
|
| 184 |
+
st.markdown(
|
| 185 |
+
f"""
|
| 186 |
+
<div class="mm-side-card">
|
| 187 |
+
<h4>🏆 Stats</h4>
|
| 188 |
+
<div class="mm-metric"><span>Current Level</span><b>{ss.mm_level}</b></div>
|
| 189 |
+
<div class="mm-metric"><span>Total XP</span><b>{ss.mm_xp}</b></div>
|
| 190 |
+
<div class="mm-metric"><span>Matches Made</span><b>{ss.mm_matches}</b></div>
|
| 191 |
+
</div>
|
| 192 |
+
""",
|
| 193 |
+
unsafe_allow_html=True,
|
| 194 |
+
)
|
| 195 |
+
st.markdown(
|
| 196 |
+
"""
|
| 197 |
+
<div class="mm-side-card">
|
| 198 |
+
<h4>How to Play</h4>
|
| 199 |
+
<ol class="mm-howto">
|
| 200 |
+
<li>Look at the target amount</li>
|
| 201 |
+
<li>Click coins and notes to add them</li>
|
| 202 |
+
<li>Match the target exactly to earn XP</li>
|
| 203 |
+
<li>Level up with each successful match</li>
|
| 204 |
+
</ol>
|
| 205 |
+
</div>
|
| 206 |
+
""",
|
| 207 |
+
unsafe_allow_html=True,
|
| 208 |
+
)
|
phase/Student_view/games/budgetbuilder.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase\Student_view\games\budgetbuilder.py
|
| 2 |
+
|
| 3 |
+
import streamlit as st
|
| 4 |
+
import os, time
|
| 5 |
+
from utils import api as backend
|
| 6 |
+
from utils import db as dbapi
|
| 7 |
+
|
| 8 |
+
DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
|
| 9 |
+
|
| 10 |
+
def _rerun():
|
| 11 |
+
try:
|
| 12 |
+
st.rerun()
|
| 13 |
+
except AttributeError:
|
| 14 |
+
st.experimental_rerun()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _refresh_global_xp():
|
| 18 |
+
user = st.session_state.get("user")
|
| 19 |
+
if not user:
|
| 20 |
+
return
|
| 21 |
+
try:
|
| 22 |
+
stats = backend.user_stats(user["user_id"]) if DISABLE_DB else dbapi.user_xp_and_level(user["user_id"])
|
| 23 |
+
st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
|
| 24 |
+
st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
|
| 25 |
+
except Exception as e:
|
| 26 |
+
st.warning(f"XP refresh failed: {e}")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _persist_budget_result(level_cfg: dict, success: bool, gained_xp: int):
|
| 30 |
+
user = st.session_state.get("user")
|
| 31 |
+
if not user:
|
| 32 |
+
st.info("Login to earn and save XP.")
|
| 33 |
+
return
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
elapsed_ms = int((time.time() - st.session_state.get("bb_start_ts", time.time())) * 1000)
|
| 37 |
+
allocations = [{"id": cid, "amount": int(val)} for cid, val in st.session_state.categories.items()]
|
| 38 |
+
budget_score = 100 if success else 0
|
| 39 |
+
|
| 40 |
+
if DISABLE_DB:
|
| 41 |
+
backend.record_budget_builder_play(
|
| 42 |
+
user_id=user["user_id"],
|
| 43 |
+
weekly_allowance=int(level_cfg["income"]),
|
| 44 |
+
budget_score=int(budget_score),
|
| 45 |
+
elapsed_ms=elapsed_ms,
|
| 46 |
+
allocations=allocations,
|
| 47 |
+
gained_xp=int(gained_xp),
|
| 48 |
+
)
|
| 49 |
+
else:
|
| 50 |
+
# Local DB path (if your db layer has one of these)
|
| 51 |
+
if hasattr(dbapi, "record_budget_builder_result"):
|
| 52 |
+
dbapi.record_budget_builder_result(
|
| 53 |
+
user_id=user["user_id"],
|
| 54 |
+
weekly_allowance=int(level_cfg["income"]),
|
| 55 |
+
budget_score=int(budget_score),
|
| 56 |
+
elapsed_ms=elapsed_ms,
|
| 57 |
+
allocations=allocations,
|
| 58 |
+
gained_xp=int(gained_xp),
|
| 59 |
+
)
|
| 60 |
+
elif hasattr(dbapi, "award_xp"):
|
| 61 |
+
dbapi.award_xp(user["user_id"], int(gained_xp), reason="budget_builder")
|
| 62 |
+
|
| 63 |
+
_refresh_global_xp() # <-- this makes the XP bar move immediately
|
| 64 |
+
except Exception as e:
|
| 65 |
+
st.warning(f"Could not save budget result: {e}")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def show_budget_builder():
|
| 69 |
+
|
| 70 |
+
# timer for elapsed_ms
|
| 71 |
+
if "bb_start_ts" not in st.session_state:
|
| 72 |
+
st.session_state.bb_start_ts = time.time()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# Add custom CSS for improved styling
|
| 76 |
+
st.markdown("""
|
| 77 |
+
<style>
|
| 78 |
+
/* Main container styling */
|
| 79 |
+
.main .block-container {
|
| 80 |
+
padding-top: 2rem;
|
| 81 |
+
padding-bottom: 2rem;
|
| 82 |
+
max-width: 1200px;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* Card-like styling for sections */
|
| 86 |
+
.budget-card {
|
| 87 |
+
background: white;
|
| 88 |
+
border-radius: 12px;
|
| 89 |
+
padding: 1.5rem;
|
| 90 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 91 |
+
margin-bottom: 1.5rem;
|
| 92 |
+
border: 1px solid #e5e7eb;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* Header styling */
|
| 96 |
+
.main h1 {
|
| 97 |
+
color: #1f2937;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
margin-bottom: 0.5rem;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.main h2 {
|
| 103 |
+
color: #374151;
|
| 104 |
+
font-weight: 600;
|
| 105 |
+
margin-bottom: 1rem;
|
| 106 |
+
font-size: 1.5rem;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.main h3 {
|
| 110 |
+
color: #4b5563;
|
| 111 |
+
font-weight: 600;
|
| 112 |
+
margin-bottom: 0.75rem;
|
| 113 |
+
font-size: 1.25rem;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/* Slider styling improvements */
|
| 117 |
+
.stSlider > div > div > div > div {
|
| 118 |
+
background-color: #f3f4f6;
|
| 119 |
+
border-radius: 8px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/* Updated button styling with specific colors for Check Budget (green) and Reset (gray) */
|
| 123 |
+
.stButton > button {
|
| 124 |
+
border-radius: 8px;
|
| 125 |
+
border: none;
|
| 126 |
+
font-weight: 600;
|
| 127 |
+
padding: 0.5rem 1rem;
|
| 128 |
+
transition: all 0.2s;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.stButton > button:hover {
|
| 132 |
+
transform: translateY(-1px);
|
| 133 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* Green button for Check Budget */
|
| 137 |
+
.stButton > button[kind="primary"] {
|
| 138 |
+
background-color: #10b981;
|
| 139 |
+
color: white;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.stButton > button[kind="primary"]:hover {
|
| 143 |
+
background-color: #059669;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
/* Gray button for Reset */
|
| 147 |
+
.stButton > button[kind="secondary"] {
|
| 148 |
+
background-color: white;
|
| 149 |
+
color: black;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.stButton > button[kind="secondary"]:hover {
|
| 153 |
+
background-color: #f3f4f6;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* Success/Error message styling */
|
| 157 |
+
.stSuccess {
|
| 158 |
+
border-radius: 8px;
|
| 159 |
+
border-left: 4px solid #10b981;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.stError {
|
| 163 |
+
border-radius: 8px;
|
| 164 |
+
border-left: 4px solid #ef4444;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* Metric styling */
|
| 168 |
+
.metric-container {
|
| 169 |
+
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
|
| 170 |
+
border-radius: 12px;
|
| 171 |
+
padding: 1rem;
|
| 172 |
+
border: 1px solid #e2e8f0;
|
| 173 |
+
text-align: center;
|
| 174 |
+
margin-bottom: 1rem;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* Table styling */
|
| 178 |
+
.stTable {
|
| 179 |
+
border-radius: 8px;
|
| 180 |
+
overflow: hidden;
|
| 181 |
+
border: 1px solid #e5e7eb;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* Progress bar styling */
|
| 185 |
+
.stProgress > div > div > div {
|
| 186 |
+
background-color: #10b981;
|
| 187 |
+
border-radius: 4px;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
/* Info box styling */
|
| 191 |
+
.stInfo {
|
| 192 |
+
border-radius: 8px;
|
| 193 |
+
border-left: 4px solid #3b82f6;
|
| 194 |
+
background-color: #eff6ff;
|
| 195 |
+
}
|
| 196 |
+
</style>
|
| 197 |
+
""", unsafe_allow_html=True)
|
| 198 |
+
|
| 199 |
+
# -----------------------------
|
| 200 |
+
# Define Levels and Categories
|
| 201 |
+
# -----------------------------
|
| 202 |
+
levels = [
|
| 203 |
+
{
|
| 204 |
+
"id": 1,
|
| 205 |
+
"title": "First Budget",
|
| 206 |
+
"description": "Learn basic budget allocation",
|
| 207 |
+
"scenario": "You're 14 and just started getting a weekly allowance. Your parents want to see you can manage money responsibly before increasing it.",
|
| 208 |
+
"income": 300,
|
| 209 |
+
"objectives": [
|
| 210 |
+
"Save at least 20% of your income",
|
| 211 |
+
"Don't spend more than 30% on entertainment",
|
| 212 |
+
"Allocate money for food and transport"
|
| 213 |
+
],
|
| 214 |
+
"constraints": {
|
| 215 |
+
"savings": {"min": 60},
|
| 216 |
+
"fun": {"max": 90},
|
| 217 |
+
"food": {"min": 40, "required": True},
|
| 218 |
+
"transport": {"min": 30, "required": True},
|
| 219 |
+
},
|
| 220 |
+
"success": [
|
| 221 |
+
("Save at least JA$60 (20%)", lambda cats, inc: cats["savings"] >= 60),
|
| 222 |
+
("Keep entertainment under JA$90 (30%)", lambda cats, inc: cats["fun"] <= 90),
|
| 223 |
+
("Balance your budget completely", lambda cats, inc: sum(cats.values()) == inc),
|
| 224 |
+
],
|
| 225 |
+
"xp": 20,
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"id": 2,
|
| 229 |
+
"title": "Emergency Fund",
|
| 230 |
+
"description": "Build an emergency fund while managing expenses",
|
| 231 |
+
"scenario": "Your phone broke last month and you had no savings to fix it. This time, build an emergency fund while still enjoying life.",
|
| 232 |
+
"income": 400,
|
| 233 |
+
"objectives": [
|
| 234 |
+
"Build an emergency fund (JA$100+)",
|
| 235 |
+
"Still save for long-term goals",
|
| 236 |
+
"Cover all essential expenses",
|
| 237 |
+
],
|
| 238 |
+
"constraints": {
|
| 239 |
+
"savings": {"min": 150}, # Emergency + regular savings
|
| 240 |
+
"food": {"min": 60, "required": True},
|
| 241 |
+
"transport": {"min": 40, "required": True},
|
| 242 |
+
"school": {"min": 20, "required": True},
|
| 243 |
+
},
|
| 244 |
+
"success": [
|
| 245 |
+
("Save at least JA$150 total", lambda cats, inc: cats["savings"] >= 150),
|
| 246 |
+
(
|
| 247 |
+
"Cover all essential expenses",
|
| 248 |
+
lambda cats, inc: cats["food"] >= 60
|
| 249 |
+
and cats["transport"] >= 40
|
| 250 |
+
and cats["school"] >= 20,
|
| 251 |
+
),
|
| 252 |
+
],
|
| 253 |
+
"xp": 30,
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"id": 3,
|
| 257 |
+
"title": "Reduced Income",
|
| 258 |
+
"description": "Manage when money is tight",
|
| 259 |
+
"scenario": "Your allowance got cut because of family finances. You need to make tough choices while still maintaining your savings habit.",
|
| 260 |
+
"income": 250,
|
| 261 |
+
"objectives": [
|
| 262 |
+
"Still save something (minimum JA$25)",
|
| 263 |
+
"Cut non-essential spending",
|
| 264 |
+
"Maintain essential expenses",
|
| 265 |
+
],
|
| 266 |
+
"constraints": {
|
| 267 |
+
"savings": {"min": 25},
|
| 268 |
+
"fun": {"max": 40},
|
| 269 |
+
"food": {"min": 50, "required": True},
|
| 270 |
+
"transport": {"min": 35, "required": True},
|
| 271 |
+
},
|
| 272 |
+
"success": [
|
| 273 |
+
("Save at least JA$25 (10%)", lambda cats, inc: cats["savings"] >= 25),
|
| 274 |
+
("Keep entertainment under JA$40", lambda cats, inc: cats["fun"] <= 40),
|
| 275 |
+
("Balance your budget", lambda cats, inc: sum(cats.values()) == inc),
|
| 276 |
+
],
|
| 277 |
+
"xp": 35,
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"id": 4,
|
| 281 |
+
"title": "Debt & Goals",
|
| 282 |
+
"description": "Pay off debt while saving for something special",
|
| 283 |
+
"scenario": "You borrowed JA$100 from your parents for a school trip. Now you need to pay it back (JA$25/week) while saving for a new game console.",
|
| 284 |
+
"income": 450,
|
| 285 |
+
"objectives": [
|
| 286 |
+
"Pay debt installment (JA$25)",
|
| 287 |
+
"Save for console (JA$50+ per week)",
|
| 288 |
+
"Don't compromise on essentials",
|
| 289 |
+
],
|
| 290 |
+
"constraints": {
|
| 291 |
+
"savings": {"min": 75}, # 50 for console + 25 debt payment
|
| 292 |
+
"food": {"min": 70, "required": True},
|
| 293 |
+
"transport": {"min": 45, "required": True},
|
| 294 |
+
"school": {"min": 30, "required": True},
|
| 295 |
+
},
|
| 296 |
+
"success": [
|
| 297 |
+
("Allocate JA$75+ for savings & debt", lambda cats, inc: cats["savings"] >= 75),
|
| 298 |
+
(
|
| 299 |
+
"Cover all essentials adequately",
|
| 300 |
+
lambda cats, inc: cats["food"] >= 70
|
| 301 |
+
and cats["transport"] >= 45
|
| 302 |
+
and cats["school"] >= 30,
|
| 303 |
+
),
|
| 304 |
+
],
|
| 305 |
+
"xp": 40,
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"id": 5,
|
| 309 |
+
"title": "Master Budgeter",
|
| 310 |
+
"description": "Handle multiple financial goals like an adult",
|
| 311 |
+
"scenario": "You're 16 now with part-time job income. Manage multiple goals: emergency fund, college savings, social life, and family contribution.",
|
| 312 |
+
"income": 600,
|
| 313 |
+
"objectives": [
|
| 314 |
+
"Build emergency fund (JA$50)",
|
| 315 |
+
"Save for college (JA$100)",
|
| 316 |
+
"Contribute to family (JA$40)",
|
| 317 |
+
"Maintain social life and hobbies",
|
| 318 |
+
],
|
| 319 |
+
"constraints": {
|
| 320 |
+
"savings": {"min": 150}, # Emergency + college
|
| 321 |
+
"charity": {"min": 40}, # Family contribution
|
| 322 |
+
"food": {"min": 80, "required": True},
|
| 323 |
+
"transport": {"min": 60, "required": True},
|
| 324 |
+
"school": {"min": 50, "required": True},
|
| 325 |
+
},
|
| 326 |
+
"success": [
|
| 327 |
+
("Save JA$150+ for future goals", lambda cats, inc: cats["savings"] >= 150),
|
| 328 |
+
("Contribute JA$40+ to family", lambda cats, inc: cats["charity"] >= 40),
|
| 329 |
+
(
|
| 330 |
+
"Balance entertainment & responsibilities",
|
| 331 |
+
lambda cats, inc: cats["fun"] >= 30 and cats["fun"] <= 150,
|
| 332 |
+
),
|
| 333 |
+
("Perfect budget balance", lambda cats, inc: sum(cats.values()) == inc),
|
| 334 |
+
],
|
| 335 |
+
"xp": 50,
|
| 336 |
+
},
|
| 337 |
+
]
|
| 338 |
+
|
| 339 |
+
# -----------------------------
|
| 340 |
+
# Initialize Session State
|
| 341 |
+
# -----------------------------
|
| 342 |
+
if "current_level" not in st.session_state:
|
| 343 |
+
st.session_state.current_level = 1
|
| 344 |
+
if "completed_levels" not in st.session_state:
|
| 345 |
+
st.session_state.completed_levels = []
|
| 346 |
+
if "categories" not in st.session_state:
|
| 347 |
+
st.session_state.categories = {}
|
| 348 |
+
if "level_completed" not in st.session_state:
|
| 349 |
+
st.session_state.level_completed = False
|
| 350 |
+
|
| 351 |
+
# -----------------------------
|
| 352 |
+
# Categories Master
|
| 353 |
+
# -----------------------------
|
| 354 |
+
categories_master = {
|
| 355 |
+
"food": {"name": "Food & Snacks", "color": "#16a34a", "icon": "🍎", "min": 0, "max": 300},
|
| 356 |
+
"savings": {"name": "Savings", "color": "#2563eb", "icon": "💰", "min": 0, "max": 400},
|
| 357 |
+
"fun": {"name": "Entertainment", "color": "#dc2626", "icon": "🎮", "min": 0, "max": 300},
|
| 358 |
+
"charity": {"name": "Charity/Family", "color": "#e11d48", "icon": "❤️", "min": 0, "max": 200},
|
| 359 |
+
"transport": {"name": "Transport", "color": "#ea580c", "icon": "🚌", "min": 0, "max": 200},
|
| 360 |
+
"school": {"name": "School Supplies", "color": "#0891b2", "icon": "📚", "min": 0, "max": 150},
|
| 361 |
+
}
|
| 362 |
+
if not st.session_state.categories:
|
| 363 |
+
st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
|
| 364 |
+
|
| 365 |
+
# -----------------------------
|
| 366 |
+
# Current Level Setup
|
| 367 |
+
# -----------------------------
|
| 368 |
+
level = [l for l in levels if l["id"] == st.session_state.current_level][0]
|
| 369 |
+
|
| 370 |
+
# Header section with improved styling
|
| 371 |
+
st.markdown(f"""
|
| 372 |
+
<div style="text-align: center; padding: 2rem 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 373 |
+
border-radius: 12px; margin-bottom: 2rem; color: white;">
|
| 374 |
+
<h1 style="color: white; margin-bottom: 0.5rem;">💵 Budget Builder</h1>
|
| 375 |
+
</div>
|
| 376 |
+
""", unsafe_allow_html=True)
|
| 377 |
+
|
| 378 |
+
# Level progress indicator
|
| 379 |
+
st.markdown(f"""
|
| 380 |
+
<div style="background: #f8fafc; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; border: 1px solid #e2e8f0;">
|
| 381 |
+
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
|
| 382 |
+
<span style="font-weight: 600; color: #374151;">Level Progress</span>
|
| 383 |
+
<span style="color: #6b7280;">{len(st.session_state.completed_levels)}/5 Complete</span>
|
| 384 |
+
</div>
|
| 385 |
+
</div>
|
| 386 |
+
""", unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
# Scenario description with better styling
|
| 389 |
+
st.markdown(f"""
|
| 390 |
+
<div style="background: #eff6ff; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem;
|
| 391 |
+
border-left: 4px solid #3b82f6;">
|
| 392 |
+
<h4 style="color: #1e40af; margin-bottom: 0.5rem;">📖 Scenario</h4>
|
| 393 |
+
<p style="color: #1f2937; margin-bottom: 1rem;">{level["scenario"]}</p>
|
| 394 |
+
<div style="background: white; border-radius: 6px; padding: 1rem; border: 1px solid #dbeafe;">
|
| 395 |
+
<strong style="color: #059669;">Weekly Income: JA${level['income']}</strong>
|
| 396 |
+
</div>
|
| 397 |
+
</div>
|
| 398 |
+
""", unsafe_allow_html=True)
|
| 399 |
+
|
| 400 |
+
# -----------------------------
|
| 401 |
+
# Two-column layout
|
| 402 |
+
# -----------------------------
|
| 403 |
+
left_col, right_col = st.columns([2, 1], gap="large")
|
| 404 |
+
|
| 405 |
+
with left_col:
|
| 406 |
+
st.markdown(f"""
|
| 407 |
+
<div class="budget-card" style="background: #f8fafc; border-left: 4px solid #10b981;">
|
| 408 |
+
<h3 style="color: #059669; margin-bottom: 1rem;">🎯 Objectives</h3>
|
| 409 |
+
{''.join([f'<div style="margin-bottom: 0.5rem; color: #374151;">• {obj}</div>' for obj in level["objectives"]])}
|
| 410 |
+
</div>
|
| 411 |
+
""", unsafe_allow_html=True)
|
| 412 |
+
|
| 413 |
+
st.markdown("""
|
| 414 |
+
<h2 style="color: #374151; margin-bottom: 1.5rem;">💰 Budget Allocation</h3>
|
| 415 |
+
<p style="color: #6b7280; margin-bottom: 1.5rem;">Use the sliders below to allocate your weekly income across different categories. Make sure to meet the objectives!</p>
|
| 416 |
+
""", unsafe_allow_html=True)
|
| 417 |
+
|
| 418 |
+
st.markdown("### 📊 Allocate Your Budget")
|
| 419 |
+
# Render sliders without dynamic inter-dependencies
|
| 420 |
+
for cid, cat in categories_master.items():
|
| 421 |
+
constraints = level["constraints"].get(cid, {})
|
| 422 |
+
min_val = 0
|
| 423 |
+
#max is set to the level income for more flexibility
|
| 424 |
+
max_val = level["income"]
|
| 425 |
+
st.session_state.categories[cid] = st.slider(
|
| 426 |
+
f"{cat['icon']} {cat['name']}",
|
| 427 |
+
min_value=min_val,
|
| 428 |
+
max_value=max_val,
|
| 429 |
+
value=st.session_state.categories[cid],
|
| 430 |
+
step=5,
|
| 431 |
+
help=f"Min: JA${min_val}, Max: JA${max_val}"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
# Calculate totals after sliders have been selected
|
| 435 |
+
total_allocated = sum(st.session_state.categories.values())
|
| 436 |
+
remaining = level["income"] - total_allocated
|
| 437 |
+
st.metric("Remaining", f"JA${remaining}", delta_color="inverse" if remaining < 0 else "normal")
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
# Remaining budget display with better styling
|
| 441 |
+
color = "#ef4444" if remaining < 0 else "#059669" if remaining == 0 else "#f59e0b"
|
| 442 |
+
st.markdown(f"""
|
| 443 |
+
<div class="metric-container" style="border-left: 4px solid {color};">
|
| 444 |
+
<h4 style="color: #6b7280; margin-bottom: 0.5rem;">Remaining Budget</h4>
|
| 445 |
+
<h2 style="color: {color}; margin: 0;">JA${remaining}</h2>
|
| 446 |
+
</div>
|
| 447 |
+
""", unsafe_allow_html=True)
|
| 448 |
+
|
| 449 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 450 |
+
|
| 451 |
+
col1, col2 = st.columns(2)
|
| 452 |
+
with col1:
|
| 453 |
+
if st.button("✅ Check Budget", use_container_width=True, type="primary"):
|
| 454 |
+
results = [(desc, fn(st.session_state.categories, level["income"])) for desc, fn in level["success"]]
|
| 455 |
+
all_passed = all(r[1] for r in results)
|
| 456 |
+
|
| 457 |
+
if all_passed and remaining == 0:
|
| 458 |
+
st.success(f"🎉 Level {level['id']} Complete! +{level['xp']} XP")
|
| 459 |
+
st.session_state.level_completed = True
|
| 460 |
+
if level["id"] not in st.session_state.completed_levels:
|
| 461 |
+
st.session_state.completed_levels.append(level["id"])
|
| 462 |
+
|
| 463 |
+
# award exactly once per level
|
| 464 |
+
award_key = f"_bb_xp_awarded_L{level['id']}"
|
| 465 |
+
if not st.session_state.get(award_key):
|
| 466 |
+
_persist_budget_result(level, success=True, gained_xp=int(level["xp"]))
|
| 467 |
+
st.session_state[award_key] = True
|
| 468 |
+
else:
|
| 469 |
+
st.error("❌ Not complete yet. Check the requirements!")
|
| 470 |
+
for desc, passed in results:
|
| 471 |
+
icon = "✅" if passed else "⚠️"
|
| 472 |
+
st.markdown(f"{icon} {desc}")
|
| 473 |
+
|
| 474 |
+
with col2:
|
| 475 |
+
# Reset button
|
| 476 |
+
if st.button("🔄 Reset Budget", use_container_width=True, type="secondary"):
|
| 477 |
+
# Reset all category amounts
|
| 478 |
+
for cid in categories_master.keys():
|
| 479 |
+
st.session_state[cid] = 0
|
| 480 |
+
# Reset the dictionary in session_state too
|
| 481 |
+
st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
|
| 482 |
+
st.session_state.level_completed = False
|
| 483 |
+
st.rerun()
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# Next Level button
|
| 487 |
+
if st.session_state.level_completed and st.session_state.current_level < len(levels):
|
| 488 |
+
if st.button("➡️ Next Level", use_container_width=True, type="primary"):
|
| 489 |
+
st.session_state.current_level += 1
|
| 490 |
+
st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
|
| 491 |
+
st.session_state.level_completed = False
|
| 492 |
+
st.session_state.bb_start_ts = time.time() # <-- reset timer
|
| 493 |
+
st.rerun()
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
with right_col:
|
| 497 |
+
criteria_html = ""
|
| 498 |
+
for desc, fn in level["success"]:
|
| 499 |
+
passed = fn(st.session_state.categories, level["income"])
|
| 500 |
+
icon = "✅" if passed else "⚠️"
|
| 501 |
+
color = "#059669" if passed else "#f59e0b"
|
| 502 |
+
criteria_html += f"<div style='margin-bottom: 0.5rem; color: {color};'>{icon} {desc}</div>"
|
| 503 |
+
|
| 504 |
+
st.markdown(f"""
|
| 505 |
+
<div class="budget-card">
|
| 506 |
+
<h3 style="color: #374151; margin-bottom: 1rem;">✅ Success Criteria</h3>
|
| 507 |
+
{criteria_html}
|
| 508 |
+
</div>
|
| 509 |
+
""", unsafe_allow_html=True)
|
| 510 |
+
|
| 511 |
+
breakdown_html = ""
|
| 512 |
+
for cid, amount in st.session_state.categories.items():
|
| 513 |
+
if amount > 0:
|
| 514 |
+
cat = categories_master[cid]
|
| 515 |
+
percentage = (amount / level["income"]) * 100
|
| 516 |
+
breakdown_html += f"""
|
| 517 |
+
<div style="display:flex; justify-content:space-between; align-items:center;
|
| 518 |
+
padding:0.5rem; margin-bottom:0.5rem; background:#f8fafc; border-radius:6px;">
|
| 519 |
+
<span style="color:#374151;">{cat['icon']} {cat['name']}</span>
|
| 520 |
+
<div style="text-align:right;">
|
| 521 |
+
<div style="font-weight:600; color:#1f2937;">JA${amount}</div>
|
| 522 |
+
<div style="font-size:0.8rem; color:#6b7280;">{percentage:.1f}%</div>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
"""
|
| 526 |
+
|
| 527 |
+
st.markdown(f"""
|
| 528 |
+
<div class="budget-card">
|
| 529 |
+
<h3 style="color:#374151; margin-bottom:1rem;">📊 Budget Breakdown</h3>
|
| 530 |
+
{breakdown_html}
|
| 531 |
+
</div>
|
| 532 |
+
""", unsafe_allow_html=True)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
st.markdown("""
|
| 536 |
+
<div class="budget-card" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
| 537 |
+
border-left: 4px solid #f59e0b;">
|
| 538 |
+
<h3 style="color: #92400e; margin-bottom: 1rem;">💡 Level Tips</h3>
|
| 539 |
+
<div style="color: #451a03;">
|
| 540 |
+
<div style="margin-bottom: 0.5rem;">💰 Start with essentials like food and transport</div>
|
| 541 |
+
<div style="margin-bottom: 0.5rem;">🎯 The 50/30/20 rule: needs, wants, savings</div>
|
| 542 |
+
<div>📊 Review and adjust your budget regularly</div>
|
| 543 |
+
</div>
|
| 544 |
+
</div>
|
| 545 |
+
""", unsafe_allow_html=True)
|
| 546 |
+
|
| 547 |
+
if len(st.session_state.completed_levels) == len(levels):
|
| 548 |
+
st.balloons()
|
| 549 |
+
st.markdown("""
|
| 550 |
+
<div style="text-align: center; padding: 2rem; background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
| 551 |
+
border-radius: 12px; color: white; margin-top: 2rem;">
|
| 552 |
+
<h2 style="color: white; margin-bottom: 1rem;">🎉 Congratulations!</h2>
|
| 553 |
+
<h3 style="color: #d1fae5; margin: 0;">You are now a Master Budgeter!</h3>
|
| 554 |
+
</div>
|
| 555 |
+
<br>
|
| 556 |
+
""", unsafe_allow_html=True)
|
| 557 |
+
|
| 558 |
+
# Show a restart button
|
| 559 |
+
if st.button("🔄 Restart Game"):
|
| 560 |
+
st.session_state.current_level = 1
|
| 561 |
+
st.session_state.completed_levels = []
|
| 562 |
+
st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
|
| 563 |
+
st.session_state.level_completed = False
|
| 564 |
+
st.session_state.bb_start_ts = time.time() # <-- reset timer
|
| 565 |
+
st.rerun()
|
phase/Student_view/games/debtdilemma.py
ADDED
|
@@ -0,0 +1,1062 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase\Student_view\games\debtdilemma.py
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import List, Optional, Dict, Literal
|
| 5 |
+
import random
|
| 6 |
+
import math
|
| 7 |
+
import os, time
|
| 8 |
+
from utils import api as backend
|
| 9 |
+
from utils import db as dbapi
|
| 10 |
+
|
| 11 |
+
DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
|
| 12 |
+
|
| 13 |
+
def load_css(file_name: str):
|
| 14 |
+
try:
|
| 15 |
+
with open(file_name, "r", encoding="utf-8") as f:
|
| 16 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 17 |
+
except FileNotFoundError:
|
| 18 |
+
st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _refresh_global_xp():
|
| 22 |
+
user = st.session_state.get("user")
|
| 23 |
+
if not user:
|
| 24 |
+
return
|
| 25 |
+
try:
|
| 26 |
+
stats = backend.user_stats(user["user_id"]) if DISABLE_DB else dbapi.user_xp_and_level(user["user_id"])
|
| 27 |
+
st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
|
| 28 |
+
st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
|
| 29 |
+
except Exception as e:
|
| 30 |
+
st.warning(f"XP refresh failed: {e}")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
DD_SCOPE_CLASS = "dd-scope"
|
| 34 |
+
|
| 35 |
+
def _ensure_dd_css():
|
| 36 |
+
"""Inject CSS for Debt Dilemma buttons once, scoped under .dd-scope."""
|
| 37 |
+
|
| 38 |
+
if st.session_state.get("_dd_css_injected"):
|
| 39 |
+
return
|
| 40 |
+
st.session_state["_dd_css_injected"] = True
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
st.markdown("""
|
| 44 |
+
<style>
|
| 45 |
+
.dd-scope .stButton > button {
|
| 46 |
+
border: none;
|
| 47 |
+
border-radius: 25px;
|
| 48 |
+
padding: 0.75rem 1.5rem;
|
| 49 |
+
font-weight: 700;
|
| 50 |
+
box-shadow: 0 4px 15px rgba(0,0,0,.2);
|
| 51 |
+
transition: all .3s ease;
|
| 52 |
+
}
|
| 53 |
+
.dd-scope .stButton > button:hover {
|
| 54 |
+
transform: translateY(-2px);
|
| 55 |
+
box-shadow: 0 6px 20px rgba(0,0,0,.3);
|
| 56 |
+
}
|
| 57 |
+
.dd-scope .dd-success .stButton > button {
|
| 58 |
+
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
| 59 |
+
color: #fff;
|
| 60 |
+
}
|
| 61 |
+
.dd-scope .dd-warning .stButton > button {
|
| 62 |
+
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
| 63 |
+
color: #000;
|
| 64 |
+
}
|
| 65 |
+
.dd-scope .dd-danger .stButton > button {
|
| 66 |
+
background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%);
|
| 67 |
+
color: #000;
|
| 68 |
+
}
|
| 69 |
+
.dd-scope .dd-neutral .stButton > button {
|
| 70 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 71 |
+
color: #fff;
|
| 72 |
+
}
|
| 73 |
+
</style>
|
| 74 |
+
""", unsafe_allow_html=True)
|
| 75 |
+
|
| 76 |
+
def buttondd(label: str, *, key: str, variant: str = "neutral", **kwargs) -> bool:
|
| 77 |
+
"""
|
| 78 |
+
Scoped button wrapper. Use just like st.button but styles are limited to the Debt Dilemma container.
|
| 79 |
+
|
| 80 |
+
Example:
|
| 81 |
+
buttondd("Pay", key="btn_pay", variant="success", on_click=fn, use_container_width=True)
|
| 82 |
+
"""
|
| 83 |
+
_ensure_dd_css()
|
| 84 |
+
|
| 85 |
+
st.markdown(f'<div class="dd-{variant}">', unsafe_allow_html=True)
|
| 86 |
+
clicked = st.button(label, key=key, **kwargs)
|
| 87 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 88 |
+
return clicked
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
setattr(st, "buttondd", buttondd)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ==== Currency & economy tuning ====
|
| 95 |
+
CURRENCY = "JMD$"
|
| 96 |
+
MONEY_SCALE = 1000 # 1 "game dollar" = 1,000 JMD
|
| 97 |
+
|
| 98 |
+
def jmd(x: int | float) -> int:
|
| 99 |
+
"""Scale a base unit to JMD integer."""
|
| 100 |
+
return int(round(x * MONEY_SCALE))
|
| 101 |
+
|
| 102 |
+
def fmt_money(x: int | float) -> str:
|
| 103 |
+
"""Format with thousands separator and currency."""
|
| 104 |
+
# round instead of floor so UI doesn't show 0 while a tiny positive remains
|
| 105 |
+
return f"{CURRENCY}{int(round(x)):,}"
|
| 106 |
+
|
| 107 |
+
def clamp_money(x: float) -> int:
|
| 108 |
+
"""Round to nearest JMD and never go negative."""
|
| 109 |
+
# helper to normalize all balances to integer JMD
|
| 110 |
+
return max(0, int(round(x)))
|
| 111 |
+
|
| 112 |
+
# Fees (scaled)
|
| 113 |
+
LATE_FEE_BASE = jmd(10) # ~JMD$10,000
|
| 114 |
+
LATE_FEE_PER_MISS = jmd(5) # +JMD$5,000 per missed
|
| 115 |
+
EMERGENCY_FEE = jmd(25) # ~JMD$25,000
|
| 116 |
+
SMALL_PROC_FEE = jmd(2) # ~JMD$2,000 for event shortfalls
|
| 117 |
+
|
| 118 |
+
# ==== Starting wallet config ====
|
| 119 |
+
START_WALLET_MIN = 0
|
| 120 |
+
START_WALLET_MAX = jmd(10) # JMD $0–10,000
|
| 121 |
+
DISBURSE_LOAN_TO_WALLET = False # keep loan off-wallet by default (e.g., pays tuition)
|
| 122 |
+
|
| 123 |
+
# --- Credit-score tuning ---
|
| 124 |
+
CS_EVENT_DECLINE_MIN = 15 # min points to deduct when you skip an expense event
|
| 125 |
+
CS_EVENT_DECLINE_MAX = 100 # max points
|
| 126 |
+
CS_EVENT_DECLINE_PER_K = 5 # ~5 pts per JMD$1,000 of expense you duck
|
| 127 |
+
CS_EMERGENCY_EVENT_HIT = 60 # when an event forces an emergency loan
|
| 128 |
+
|
| 129 |
+
# --- Utilities month-end penalties ---
|
| 130 |
+
UTILITY_NONPAY_CS_HIT = 25
|
| 131 |
+
UTILITY_NONPAY_HAPPY_HIT = 8
|
| 132 |
+
UTILITY_RECONNECT_FEE = jmd(2) # ~JMD$2,000 added to debt
|
| 133 |
+
|
| 134 |
+
# ===============================
|
| 135 |
+
# Types
|
| 136 |
+
# ===============================
|
| 137 |
+
@dataclass
|
| 138 |
+
class LoanDetails:
|
| 139 |
+
principal: int
|
| 140 |
+
interestRate: float
|
| 141 |
+
monthlyPayment: int
|
| 142 |
+
totalOwed: float
|
| 143 |
+
monthsPaid: int
|
| 144 |
+
totalMonths: int
|
| 145 |
+
missedPayments: int
|
| 146 |
+
creditScore: int
|
| 147 |
+
|
| 148 |
+
@dataclass
|
| 149 |
+
class RandomEvent:
|
| 150 |
+
id: str
|
| 151 |
+
title: str
|
| 152 |
+
description: str
|
| 153 |
+
icon: str
|
| 154 |
+
type: Literal['opportunity','expense','penalty','bonus']
|
| 155 |
+
impact: Dict[str, int] = field(default_factory=dict)
|
| 156 |
+
choices: Optional[Dict[str,str]] = None
|
| 157 |
+
|
| 158 |
+
@dataclass
|
| 159 |
+
class GameLevel:
|
| 160 |
+
level: int
|
| 161 |
+
name: str
|
| 162 |
+
loanAmount: int
|
| 163 |
+
interestRate: float
|
| 164 |
+
monthlyPayment: int
|
| 165 |
+
totalMonths: int
|
| 166 |
+
startingIncome: int
|
| 167 |
+
description: str
|
| 168 |
+
|
| 169 |
+
# ===============================
|
| 170 |
+
# Data (shorter game: 3 levels)
|
| 171 |
+
# ===============================
|
| 172 |
+
GAME_LEVELS: List[GameLevel] = [
|
| 173 |
+
GameLevel(1, "🎓 Student Loan", jmd(100), 0.15, jmd(25), 3, jmd(120), "Your first small loan as a student - let's learn together! 📚"),
|
| 174 |
+
GameLevel(2, "🚗 Car Loan", jmd(250), 0.18, jmd(50), 3, jmd(140), "Buying your first car - bigger responsibility but you've got this! 🌟"),
|
| 175 |
+
GameLevel(3, "💳 Credit Card Debt", jmd(400), 0.22, jmd(70), 3, jmd(160), "High-interest credit card debt - time to be extra careful! ⚠️"),
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
+
# Replaced 'Clothes' with 'Snacks' and added happiness boosts
|
| 179 |
+
EXPENSES = [
|
| 180 |
+
{"id": "food", "name": "Food", "amount": jmd(0.9), "required": True, "healthImpact": -15, "emoji": "🍎"}, # ~JMD$900 per day
|
| 181 |
+
{"id": "transport", "name": "Transport", "amount": jmd(0.5), "required": True, "healthImpact": 0, "emoji": "🚌"}, # ~JMD$500
|
| 182 |
+
{"id": "utilities", "name": "Utilities", "amount": jmd(7), "required": True, "healthImpact": -5, "emoji": "💡"}, # ~JMD$7,000/mo
|
| 183 |
+
{"id": "entertainment","name": "Entertainment","amount": jmd(1.5), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🎮"},
|
| 184 |
+
{"id": "snacks", "name": "Snacks", "amount": jmd(0.8), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🍿"},
|
| 185 |
+
]
|
| 186 |
+
|
| 187 |
+
LEVEL_EVENT_POOL = {
|
| 188 |
+
1: [ # Student Loan level
|
| 189 |
+
RandomEvent("games_day", "🏟️ School Games Day", "Your school is holding Games Day. Small fee, but huge fun and morale!", "🏟️", "expense",
|
| 190 |
+
{"wallet": -jmd(2), "happiness": 5}, {"accept": f"Join ({fmt_money(jmd(2))}, +5% happy)", "decline": "Skip"}),
|
| 191 |
+
RandomEvent("book_fair", "📚 Book Fair", "Discounted textbooks help your grades (and future pay!).", "📚", "opportunity",
|
| 192 |
+
{"wallet": -jmd(3), "income": jmd(1), "happiness": 3}, {"accept": "Buy books", "decline": "Pass"}),
|
| 193 |
+
RandomEvent("tuition_deadline", "🎓 Tuition Deadline", "A small admin fee pops up unexpectedly.", "🎓", "expense",
|
| 194 |
+
{"wallet": -jmd(3.5)}, {"accept": "Pay fee", "decline": "Appeal"}),
|
| 195 |
+
],
|
| 196 |
+
2: [ # Car Loan level
|
| 197 |
+
RandomEvent("gas_hike", "⛽ Gas Price Hike", "Fuel costs rise this week.", "⛽", "expense",
|
| 198 |
+
{"wallet": -jmd(2.5)}, {"accept": "Buy gas", "decline": "Drive less"}),
|
| 199 |
+
RandomEvent("oil_change", "🛠️ Discount Oil Change", "Maintenance now saves larger repair later.", "🛠️", "opportunity",
|
| 200 |
+
{"wallet": -jmd(3), "creditScore": 5}),
|
| 201 |
+
],
|
| 202 |
+
3: [ # Credit Card level
|
| 203 |
+
RandomEvent("flash_sale", "🛍️ Flash Sale Temptation", "Limited-time sale! Tempting but watch your debt.", "🛍️", "penalty",
|
| 204 |
+
{"debt": jmd(4), "happiness": 4}, {"accept": "Buy (+debt)", "decline": "Resist"}),
|
| 205 |
+
RandomEvent("cashback", "💳 Cashback Bonus", "Your card offers a cashback promo.", "💳", "bonus",
|
| 206 |
+
{"wallet": jmd(3)}),
|
| 207 |
+
],
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
EVENT_POOL: List[RandomEvent] = [
|
| 211 |
+
# Money-earning opportunities
|
| 212 |
+
RandomEvent("yard_sale", "🧹 Yard Sale Fun!", "You sell old items and make some quick cash! Great job being resourceful! 🌟", "🧹", "opportunity", {"wallet": jmd(6)}),
|
| 213 |
+
RandomEvent("tutoring", "📚 Tutoring Helper", "You help someone with homework and get paid! Sharing knowledge feels great! 😊", "📚", "opportunity", {"wallet": jmd(5)}),
|
| 214 |
+
RandomEvent("odd_jobs", "🧰 Weekend Helper", "You mow lawns and wash a car over the weekend! Hard work pays off! 💪", "🧰", "opportunity", {"wallet": jmd(7)}),
|
| 215 |
+
|
| 216 |
+
# Bonuses / grants
|
| 217 |
+
RandomEvent("overtime_work", "💼 Extra Work Time", "Your boss offers you overtime this period. Extra money but you'll be tired! 😴", "💼", "opportunity",
|
| 218 |
+
{"wallet": jmd(8)}, {"accept": f"Work overtime (+{fmt_money(jmd(8))}) 💪", "decline": "Rest instead 😴"}),
|
| 219 |
+
RandomEvent("freelance_job", "💻 Weekend Project", "A friend asks you to help with their business for some quick cash! 🤝", "💻", "opportunity",
|
| 220 |
+
{"wallet": jmd(6)}, {"accept": f"Take the job (+{fmt_money(jmd(6))}) 💼", "decline": "Enjoy your weekend 🌈"}),
|
| 221 |
+
RandomEvent("bonus_payment", "⭐ Amazing Work!", "Your excellent work this period earned you a bonus! You're doing great! 🎉", "⭐", "bonus", {"wallet": jmd(5), "creditScore": 10}),
|
| 222 |
+
RandomEvent("scholarship_opportunity", "🎓 Learning Reward", "You qualify for a small educational grant! Knowledge pays off! 📖", "🎓", "bonus", {"wallet": jmd(10), "income": jmd(2)}),
|
| 223 |
+
|
| 224 |
+
# Health & happiness helpers
|
| 225 |
+
RandomEvent("mental_health", "🧠 Feeling Better", "A free counseling session can help you feel better and happier! 🌈", "🧠", "opportunity",
|
| 226 |
+
{"wallet": 0, "health": 10, "happiness": 10}, {"accept": "Feel better! 😊", "decline": "Maybe later 🤔"}),
|
| 227 |
+
RandomEvent("health_checkup", "🏥 Health Check", "Local clinic does a free health checkup! Taking care of yourself is important! 💚", "🏥", "opportunity",
|
| 228 |
+
{"wallet": 0, "health": 10, "happiness": 5}, {"accept": "Get healthy! 💪", "decline": "Skip it 🤷"}),
|
| 229 |
+
|
| 230 |
+
# Expenses / penalties
|
| 231 |
+
RandomEvent("landlord_eviction", "🏠 Moving Costs", "You need a small deposit for a new place soon. Moving can be expensive! 📦", "🏠", "expense",
|
| 232 |
+
{"wallet": -jmd(9)}, {"accept": f"Pay deposit (-{fmt_money(jmd(9))}) 🏠", "decline": "Try to negotiate 🤝"}),
|
| 233 |
+
RandomEvent("transport_breakdown", "🚫 Transport Trouble", "Your usual transport is down. You need an alternative way to get around! 🚶", "🚫", "expense",
|
| 234 |
+
{"wallet": -jmd(3)}, {"accept": f"Pay for ride (-{fmt_money(jmd(3))}) 🚗", "decline": "Walk everywhere 🚶"}),
|
| 235 |
+
RandomEvent("utilities_shutoff", "⚡ Utility Warning", "Utilities will be shut off if not paid soon! Don't let the lights go out! 💡", "⚡", "expense",
|
| 236 |
+
{"wallet": -jmd(4)}, {"accept": f"Pay now (-{fmt_money(jmd(4))}) 💡", "decline": "Risk it 😬"}),
|
| 237 |
+
]
|
| 238 |
+
|
| 239 |
+
# ===============================
|
| 240 |
+
# Helpers
|
| 241 |
+
# ===============================
|
| 242 |
+
def get_level(level:int) -> GameLevel:
|
| 243 |
+
return GAME_LEVELS[level-1]
|
| 244 |
+
|
| 245 |
+
def required_expenses_total() -> int:
|
| 246 |
+
return sum(e["amount"] for e in EXPENSES if e["required"])
|
| 247 |
+
|
| 248 |
+
def progress_percent(total_owed: float, monthly_payment: int, total_months: int) -> float:
|
| 249 |
+
pct = ((total_months - (total_owed / max(monthly_payment,1))) / total_months) * 100
|
| 250 |
+
return max(0.0, min(100.0, pct))
|
| 251 |
+
|
| 252 |
+
def payoff_projection(balance: float, apr: float, monthly_payment: int):
|
| 253 |
+
"""
|
| 254 |
+
Simulate payoff using the game's timing:
|
| 255 |
+
- Player pays during the month (before interest).
|
| 256 |
+
- At month end, interest accrues on the remaining balance and is added.
|
| 257 |
+
Returns (months_needed, total_interest_paid). If payment <= interest, returns (None, None).
|
| 258 |
+
"""
|
| 259 |
+
r = apr / 12.0
|
| 260 |
+
if balance <= 0:
|
| 261 |
+
return 0, 0
|
| 262 |
+
if r <= 0:
|
| 263 |
+
months = math.ceil(balance / max(1, monthly_payment))
|
| 264 |
+
return months, 0
|
| 265 |
+
if monthly_payment <= balance * r:
|
| 266 |
+
return None, None
|
| 267 |
+
months = 0
|
| 268 |
+
total_interest = 0.0
|
| 269 |
+
b = float(balance)
|
| 270 |
+
for _ in range(10000): # safety cap
|
| 271 |
+
pay = min(monthly_payment, b)
|
| 272 |
+
b -= pay
|
| 273 |
+
months += 1
|
| 274 |
+
if b <= 1e-6:
|
| 275 |
+
break
|
| 276 |
+
interest = b * r
|
| 277 |
+
b += interest
|
| 278 |
+
total_interest += interest
|
| 279 |
+
if monthly_payment <= b * r - 1e-9:
|
| 280 |
+
return None, None
|
| 281 |
+
return months, int(round(total_interest))
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _award_level_completion_if_needed():
|
| 285 |
+
"""Give exactly +50 XP once per completed level, including the last level."""
|
| 286 |
+
user = st.session_state.get("user")
|
| 287 |
+
if not user:
|
| 288 |
+
return
|
| 289 |
+
lvl = int(st.session_state.currentLevel)
|
| 290 |
+
key = f"_dd_xp_awarded_L{lvl}"
|
| 291 |
+
if st.session_state.get(key):
|
| 292 |
+
return # already awarded for this level
|
| 293 |
+
|
| 294 |
+
try:
|
| 295 |
+
# compute elapsed time for the level
|
| 296 |
+
start_ts = st.session_state.get("dd_start_ts", time.time())
|
| 297 |
+
elapsed_ms = int(max(0, (time.time() - start_ts) * 1000))
|
| 298 |
+
|
| 299 |
+
if DISABLE_DB:
|
| 300 |
+
# call backend Space
|
| 301 |
+
backend.record_debt_dilemma_play(
|
| 302 |
+
user_id=user["user_id"],
|
| 303 |
+
loans_cleared=1, # you completed the level
|
| 304 |
+
mistakes=int(st.session_state.loan.missedPayments),
|
| 305 |
+
elapsed_ms=elapsed_ms,
|
| 306 |
+
gained_xp=50,
|
| 307 |
+
)
|
| 308 |
+
else:
|
| 309 |
+
# local DB path kept for dev mode
|
| 310 |
+
dbapi.record_debt_dilemma_round(
|
| 311 |
+
user["user_id"],
|
| 312 |
+
level=lvl,
|
| 313 |
+
round_no=0,
|
| 314 |
+
wallet=int(st.session_state.wallet),
|
| 315 |
+
health=int(st.session_state.health),
|
| 316 |
+
happiness=int(st.session_state.happiness),
|
| 317 |
+
credit_score=int(st.session_state.loan.creditScore),
|
| 318 |
+
event_json={"phase": st.session_state.gamePhase},
|
| 319 |
+
outcome=("level_complete" if st.session_state.gamePhase == "level-complete" else "game_complete"),
|
| 320 |
+
gained_xp=50,
|
| 321 |
+
elapsed_ms=elapsed_ms,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
st.session_state[key] = True
|
| 325 |
+
_refresh_global_xp()
|
| 326 |
+
st.success("Saved +50 XP for completing this loan")
|
| 327 |
+
except Exception as e:
|
| 328 |
+
st.error(f"Could not save completion XP: {e}")
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def check_loan_completion() -> bool:
|
| 332 |
+
"""Advance level or finish game when loan is cleared. Returns True if game phase changed."""
|
| 333 |
+
loan = st.session_state.loan
|
| 334 |
+
# use integerized check; tiny float dust won't block completion
|
| 335 |
+
if clamp_money(loan.totalOwed) == 0:
|
| 336 |
+
if st.session_state.currentLevel < len(GAME_LEVELS):
|
| 337 |
+
st.session_state.gamePhase = "level-complete"
|
| 338 |
+
st.toast(f"Level {st.session_state.currentLevel} complete! Ready for next?")
|
| 339 |
+
else:
|
| 340 |
+
st.session_state.gamePhase = "completed"
|
| 341 |
+
st.toast("All levels done! 🎉")
|
| 342 |
+
return True
|
| 343 |
+
return False
|
| 344 |
+
|
| 345 |
+
def init_state():
|
| 346 |
+
if "gamePhase" not in st.session_state:
|
| 347 |
+
st.session_state.update({
|
| 348 |
+
"gamePhase": "setup",
|
| 349 |
+
"currentMonth": 1,
|
| 350 |
+
"currentDay": 1,
|
| 351 |
+
"daysInMonth": 28,
|
| 352 |
+
"roundsLeft": 6,
|
| 353 |
+
"wallet": random.randint(START_WALLET_MIN, START_WALLET_MAX),
|
| 354 |
+
"monthlyIncome": GAME_LEVELS[0].startingIncome,
|
| 355 |
+
"health": 100,
|
| 356 |
+
"happiness": 100,
|
| 357 |
+
"monthsWithoutFood": 0,
|
| 358 |
+
"currentEvent": None,
|
| 359 |
+
"eventHistory": [],
|
| 360 |
+
"difficultyMultiplier": 1.0,
|
| 361 |
+
"currentLevel": 1,
|
| 362 |
+
"paidExpenses": [],
|
| 363 |
+
"hasWorkedThisMonth": False,
|
| 364 |
+
"achievements": [],
|
| 365 |
+
"lastWorkPeriod": 0,
|
| 366 |
+
"amountPaidThisMonth": 0,
|
| 367 |
+
"fullPaymentMadeThisMonth": False,
|
| 368 |
+
"paidFoodToday": False,
|
| 369 |
+
})
|
| 370 |
+
lvl = get_level(1)
|
| 371 |
+
st.session_state["loan"] = LoanDetails(
|
| 372 |
+
principal=lvl.loanAmount,
|
| 373 |
+
interestRate=lvl.interestRate,
|
| 374 |
+
monthlyPayment=lvl.monthlyPayment,
|
| 375 |
+
totalOwed=float(lvl.loanAmount),
|
| 376 |
+
monthsPaid=0,
|
| 377 |
+
totalMonths=lvl.totalMonths,
|
| 378 |
+
missedPayments=0,
|
| 379 |
+
creditScore=random.randint(200, 600),
|
| 380 |
+
)
|
| 381 |
+
if "dd_start_ts" not in st.session_state:
|
| 382 |
+
st.session_state.dd_start_ts = time.time()
|
| 383 |
+
|
| 384 |
+
# Fortnight helper (every 2 weeks)
|
| 385 |
+
def current_fortnight() -> int:
|
| 386 |
+
return 1 + (st.session_state.currentDay - 1) // 14
|
| 387 |
+
|
| 388 |
+
# ===== End checks =====
|
| 389 |
+
def check_end_conditions() -> bool:
|
| 390 |
+
if st.session_state.health <= 0:
|
| 391 |
+
st.session_state.health = 0
|
| 392 |
+
st.session_state.gamePhase = "hospital"
|
| 393 |
+
st.toast("You've been hospitalized! Health reached 0%. Game Over.")
|
| 394 |
+
return True
|
| 395 |
+
if st.session_state.happiness <= 0:
|
| 396 |
+
st.session_state.happiness = 0
|
| 397 |
+
st.session_state.gamePhase = "burnout"
|
| 398 |
+
st.toast("Happiness reached 0%. You gave up. Game Over.")
|
| 399 |
+
return True
|
| 400 |
+
return False
|
| 401 |
+
|
| 402 |
+
# ===== Day advancement =====
|
| 403 |
+
def advance_day(no_event: bool = False):
|
| 404 |
+
"""Advance one day. If no_event=True, skip daily event roll (use when an action already consumed the day)."""
|
| 405 |
+
if st.session_state.gamePhase == "repaying":
|
| 406 |
+
if not st.session_state.paidFoodToday:
|
| 407 |
+
st.session_state.health = max(0, st.session_state.health - 5)
|
| 408 |
+
st.toast("You skipped food today. Health -5%")
|
| 409 |
+
st.session_state.paidFoodToday = False
|
| 410 |
+
|
| 411 |
+
if not no_event and st.session_state.gamePhase == "repaying" and st.session_state.currentEvent is None:
|
| 412 |
+
new_evt = gen_random_event()
|
| 413 |
+
if new_evt:
|
| 414 |
+
st.session_state.currentEvent = new_evt
|
| 415 |
+
st.toast(f"New event: {new_evt.title}")
|
| 416 |
+
return
|
| 417 |
+
|
| 418 |
+
if check_end_conditions():
|
| 419 |
+
return
|
| 420 |
+
|
| 421 |
+
st.session_state.currentDay += 1
|
| 422 |
+
if st.session_state.currentDay > st.session_state.daysInMonth:
|
| 423 |
+
st.session_state.currentDay = 1
|
| 424 |
+
next_month()
|
| 425 |
+
else:
|
| 426 |
+
st.toast(f"Day {st.session_state.currentDay}/{st.session_state.daysInMonth}")
|
| 427 |
+
|
| 428 |
+
def fast_forward_to_month_end():
|
| 429 |
+
st.toast("Skipping to month end…")
|
| 430 |
+
st.session_state.currentDay = st.session_state.daysInMonth
|
| 431 |
+
next_month()
|
| 432 |
+
|
| 433 |
+
# ===============================
|
| 434 |
+
# Random events
|
| 435 |
+
# ===============================
|
| 436 |
+
def set_event(evt: Optional[RandomEvent]):
|
| 437 |
+
st.session_state["currentEvent"] = evt
|
| 438 |
+
|
| 439 |
+
def gen_random_event() -> Optional[RandomEvent]:
|
| 440 |
+
currentMonth = st.session_state.currentMonth
|
| 441 |
+
difficulty = st.session_state.difficultyMultiplier
|
| 442 |
+
base = 0.08
|
| 443 |
+
eventChance = min(base + (currentMonth * 0.03) + (difficulty * 0.02), 0.4)
|
| 444 |
+
if random.random() < eventChance:
|
| 445 |
+
seen = set(st.session_state.eventHistory)
|
| 446 |
+
level_specific = LEVEL_EVENT_POOL.get(st.session_state.currentLevel, [])
|
| 447 |
+
pool = EVENT_POOL + level_specific
|
| 448 |
+
available = [e for e in pool if (e.id not in seen) or (e.type in ("opportunity","bonus"))]
|
| 449 |
+
if available:
|
| 450 |
+
return random.choice(available)
|
| 451 |
+
return None
|
| 452 |
+
|
| 453 |
+
# ===============================
|
| 454 |
+
# Game actions
|
| 455 |
+
# ===============================
|
| 456 |
+
def start_loan():
|
| 457 |
+
st.session_state.dd_start_ts = time.time() # start elapsed timer
|
| 458 |
+
st.session_state.gamePhase = "repaying"
|
| 459 |
+
if DISBURSE_LOAN_TO_WALLET:
|
| 460 |
+
st.session_state.wallet += st.session_state.loan.principal
|
| 461 |
+
st.toast(f"Loan approved! {fmt_money(st.session_state.loan.principal)} added to your wallet.")
|
| 462 |
+
else:
|
| 463 |
+
st.toast("Loan approved! Funds go directly to fees (not your wallet).")
|
| 464 |
+
|
| 465 |
+
def do_skip_payment():
|
| 466 |
+
loan: LoanDetails = st.session_state.loan
|
| 467 |
+
loan.missedPayments += 1
|
| 468 |
+
loan.creditScore = max(300, loan.creditScore - 50)
|
| 469 |
+
st.toast("Payment missed! Credit score -50.")
|
| 470 |
+
advance_day(no_event=False)
|
| 471 |
+
|
| 472 |
+
def can_work_this_period() -> bool:
|
| 473 |
+
return st.session_state.lastWorkPeriod != current_fortnight()
|
| 474 |
+
|
| 475 |
+
WORK_HAPPINESS_COST = 10
|
| 476 |
+
WORK_MIN = jmd(6) # ~JMD$6,000
|
| 477 |
+
WORK_VAR = jmd(3) # up to +JMD$3,000
|
| 478 |
+
|
| 479 |
+
def do_work_for_money():
|
| 480 |
+
if not can_work_this_period():
|
| 481 |
+
st.toast("You already worked this fortnight. Try later.")
|
| 482 |
+
return
|
| 483 |
+
earnings = WORK_MIN + random.randint(0, WORK_VAR)
|
| 484 |
+
st.session_state.wallet += earnings
|
| 485 |
+
st.session_state.happiness = max(0, st.session_state.happiness - WORK_HAPPINESS_COST)
|
| 486 |
+
st.session_state.hasWorkedThisMonth = True
|
| 487 |
+
st.session_state.lastWorkPeriod = current_fortnight()
|
| 488 |
+
st.toast(f"Work done! +{fmt_money(earnings)}, Happiness -{WORK_HAPPINESS_COST} (uses 1 day)")
|
| 489 |
+
if st.session_state.happiness <= 30 and "workaholic" not in st.session_state.achievements:
|
| 490 |
+
st.session_state.achievements.append("workaholic")
|
| 491 |
+
st.toast("Achievement: Workaholic - Worked while happiness was low!")
|
| 492 |
+
if not check_end_conditions():
|
| 493 |
+
advance_day(no_event=True)
|
| 494 |
+
|
| 495 |
+
def do_make_payment_full():
|
| 496 |
+
make_payment(st.session_state.loan.monthlyPayment)
|
| 497 |
+
|
| 498 |
+
def do_make_payment_partial():
|
| 499 |
+
# use ceil so a tiny remainder (e.g., 0.4 JMD) can be fully cleared
|
| 500 |
+
leftover = math.ceil(st.session_state.loan.totalOwed)
|
| 501 |
+
pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), leftover))
|
| 502 |
+
make_payment(pay_what)
|
| 503 |
+
|
| 504 |
+
def make_payment(amount: int):
|
| 505 |
+
loan: LoanDetails = st.session_state.loan
|
| 506 |
+
required_total = required_expenses_total()
|
| 507 |
+
if amount <= 0:
|
| 508 |
+
st.toast("Enter a valid payment amount.")
|
| 509 |
+
return
|
| 510 |
+
if st.session_state.wallet >= amount and st.session_state.wallet - amount >= required_total:
|
| 511 |
+
st.session_state.wallet -= amount
|
| 512 |
+
# clamp debt after payment to eliminate float dust
|
| 513 |
+
loan.totalOwed = clamp_money(loan.totalOwed - amount)
|
| 514 |
+
st.session_state.amountPaidThisMonth += amount
|
| 515 |
+
if amount >= loan.monthlyPayment:
|
| 516 |
+
loan.monthsPaid += 1
|
| 517 |
+
st.session_state.fullPaymentMadeThisMonth = True
|
| 518 |
+
st.toast(f"Payment successful! {fmt_money(amount)} paid.")
|
| 519 |
+
else:
|
| 520 |
+
still = max(0, loan.monthlyPayment - amount)
|
| 521 |
+
st.toast(f"Partial payment {fmt_money(amount)}. Need {fmt_money(still)} more for full.")
|
| 522 |
+
# rely on integerized zero check
|
| 523 |
+
if check_loan_completion():
|
| 524 |
+
return
|
| 525 |
+
if st.session_state.gamePhase == "repaying":
|
| 526 |
+
advance_day(no_event=True)
|
| 527 |
+
else:
|
| 528 |
+
st.toast("Not enough money (remember mandatory expenses).")
|
| 529 |
+
|
| 530 |
+
# Paying expenses is free; Food heals
|
| 531 |
+
def pay_expense(expense: Dict):
|
| 532 |
+
if st.session_state.wallet >= expense["amount"]:
|
| 533 |
+
st.session_state.wallet -= expense["amount"]
|
| 534 |
+
if expense["id"] not in st.session_state.paidExpenses:
|
| 535 |
+
st.session_state.paidExpenses.append(expense["id"])
|
| 536 |
+
st.toast(f"Paid {fmt_money(expense['amount'])} for {expense['name']}")
|
| 537 |
+
boost = int(expense.get("happinessBoost", 0))
|
| 538 |
+
if boost:
|
| 539 |
+
before = st.session_state.happiness
|
| 540 |
+
st.session_state.happiness = min(100, st.session_state.happiness + boost)
|
| 541 |
+
st.toast(f"Happiness +{st.session_state.happiness - before}%")
|
| 542 |
+
if expense["id"] == "food":
|
| 543 |
+
st.session_state.paidFoodToday = True
|
| 544 |
+
before_h = st.session_state.health
|
| 545 |
+
st.session_state.health = min(100, st.session_state.health + 10)
|
| 546 |
+
healed = st.session_state.health - before_h
|
| 547 |
+
if healed > 0:
|
| 548 |
+
st.toast(f"Health +{healed}% from eating well")
|
| 549 |
+
if expense["id"] == "utilities":
|
| 550 |
+
before = st.session_state.happiness
|
| 551 |
+
st.session_state.happiness = max(0, st.session_state.happiness - 3)
|
| 552 |
+
st.toast(f"Happiness -{before - st.session_state.happiness}% (paid utilities)")
|
| 553 |
+
check_end_conditions()
|
| 554 |
+
else:
|
| 555 |
+
st.toast(f"Can't afford {expense['name']}! It will be auto-deducted at month end.")
|
| 556 |
+
|
| 557 |
+
# Resolving events: consumes 1 day
|
| 558 |
+
def handle_event_choice(accept: bool):
|
| 559 |
+
evt: Optional[RandomEvent] = st.session_state.currentEvent
|
| 560 |
+
if not evt:
|
| 561 |
+
return
|
| 562 |
+
loan: LoanDetails = st.session_state.loan
|
| 563 |
+
|
| 564 |
+
if accept and evt.impact:
|
| 565 |
+
if "wallet" in evt.impact:
|
| 566 |
+
delta = evt.impact["wallet"]
|
| 567 |
+
if delta < 0 and st.session_state.wallet < abs(delta):
|
| 568 |
+
st.toast("You can't afford this! Emergency loan taken.")
|
| 569 |
+
short = abs(delta) + SMALL_PROC_FEE
|
| 570 |
+
st.session_state.wallet = 0
|
| 571 |
+
# clamp after adding emergency shortfall
|
| 572 |
+
loan.totalOwed = clamp_money(loan.totalOwed + short)
|
| 573 |
+
loan.creditScore = max(300,loan.creditScore - CS_EMERGENCY_EVENT_HIT)
|
| 574 |
+
st.toast(f"Added to debt: {fmt_money(short)}")
|
| 575 |
+
else:
|
| 576 |
+
st.session_state.wallet += delta
|
| 577 |
+
st.toast(f"{'+' if delta>0 else ''}{fmt_money(delta)} {'earned' if delta>0 else 'spent'}.")
|
| 578 |
+
if "income" in evt.impact:
|
| 579 |
+
st.session_state.monthlyIncome = max(jmd(0.05), st.session_state.monthlyIncome + evt.impact["income"])
|
| 580 |
+
if "creditScore" in evt.impact:
|
| 581 |
+
loan.creditScore = max(300, min(850, loan.creditScore + evt.impact["creditScore"]))
|
| 582 |
+
if "debt" in evt.impact:
|
| 583 |
+
# clamp after debt increase from event
|
| 584 |
+
loan.totalOwed = clamp_money(loan.totalOwed + evt.impact["debt"])
|
| 585 |
+
if "health" in evt.impact:
|
| 586 |
+
st.session_state.health = min(100, max(0, st.session_state.health + evt.impact["health"]))
|
| 587 |
+
if "happiness" in evt.impact:
|
| 588 |
+
st.session_state.happiness = min(100, max(0, st.session_state.happiness + evt.impact["happiness"]))
|
| 589 |
+
elif not accept:
|
| 590 |
+
if evt.type == "expense":
|
| 591 |
+
st.toast("You avoided the expense but there might be consequences…")
|
| 592 |
+
if random.random() < 0.5 and "wallet" in evt.impact:
|
| 593 |
+
base_k = abs(evt.impact["wallet"]) / MONEY_SCALE # convert JMD → 'thousands'
|
| 594 |
+
penalty = int(round(base_k * CS_EVENT_DECLINE_PER_K))
|
| 595 |
+
penalty = max(CS_EVENT_DECLINE_MIN, min(CS_EVENT_DECLINE_MAX, penalty))
|
| 596 |
+
loan.creditScore = max(300, loan.creditScore - penalty)
|
| 597 |
+
st.toast(f"Credit score penalty: -{penalty}")
|
| 598 |
+
else:
|
| 599 |
+
st.toast("You declined the opportunity.")
|
| 600 |
+
|
| 601 |
+
st.session_state.eventHistory.append(evt.id)
|
| 602 |
+
st.session_state.difficultyMultiplier += 0.1
|
| 603 |
+
st.session_state.currentEvent = None
|
| 604 |
+
if not check_end_conditions():
|
| 605 |
+
advance_day(no_event=True)
|
| 606 |
+
|
| 607 |
+
# ===============================
|
| 608 |
+
# Month processing
|
| 609 |
+
# ===============================
|
| 610 |
+
def check_achievements():
|
| 611 |
+
if st.session_state.health == 100 and "perfect-health" not in st.session_state.achievements:
|
| 612 |
+
st.session_state.achievements.append("perfect-health")
|
| 613 |
+
st.toast("Achievement: Perfect Health!")
|
| 614 |
+
if st.session_state.health <= 20 and "survivor" not in st.session_state.achievements:
|
| 615 |
+
st.session_state.achievements.append("survivor")
|
| 616 |
+
st.toast("Achievement: Survivor!")
|
| 617 |
+
if st.session_state.happiness >= 90 and "happy-camper" not in st.session_state.achievements:
|
| 618 |
+
st.session_state.achievements.append("happy-camper")
|
| 619 |
+
st.toast("Achievement: Happy Camper!")
|
| 620 |
+
if st.session_state.wallet <= jmd(0.01) and st.session_state.happiness >= 50 and "broke-not-broken" not in st.session_state.achievements:
|
| 621 |
+
st.session_state.achievements.append("broke-not-broken")
|
| 622 |
+
st.toast("Achievement: Broke But Not Broken!")
|
| 623 |
+
if st.session_state.loan.creditScore >= 800 and "credit-master" not in st.session_state.achievements:
|
| 624 |
+
st.session_state.achievements.append("credit-master")
|
| 625 |
+
st.toast("Achievement: Credit Master!")
|
| 626 |
+
|
| 627 |
+
def next_month():
|
| 628 |
+
loan: LoanDetails = st.session_state.loan
|
| 629 |
+
st.session_state.lastWorkPeriod = 0
|
| 630 |
+
|
| 631 |
+
if st.session_state.gamePhase == "repaying":
|
| 632 |
+
if not st.session_state.fullPaymentMadeThisMonth:
|
| 633 |
+
loan.missedPayments += 1
|
| 634 |
+
st.toast("You missed this month’s full payment.")
|
| 635 |
+
st.session_state.amountPaidThisMonth = 0
|
| 636 |
+
st.session_state.fullPaymentMadeThisMonth = False
|
| 637 |
+
|
| 638 |
+
unpaid = [e for e in EXPENSES if e["required"] and e["id"] not in st.session_state.paidExpenses]
|
| 639 |
+
total_forced = sum(e["amount"] for e in unpaid)
|
| 640 |
+
total_health_loss = sum(abs(e.get("healthImpact", 0)) for e in unpaid)
|
| 641 |
+
|
| 642 |
+
if total_forced > 0:
|
| 643 |
+
if st.session_state.wallet >= total_forced:
|
| 644 |
+
st.session_state.wallet -= total_forced
|
| 645 |
+
st.session_state.health = max(0, st.session_state.health - total_health_loss)
|
| 646 |
+
st.toast(f"Mandatory expenses auto-deducted: {fmt_money(total_forced)}, Health -{total_health_loss}")
|
| 647 |
+
else:
|
| 648 |
+
shortfall = total_forced - st.session_state.wallet
|
| 649 |
+
st.session_state.wallet = 0
|
| 650 |
+
st.session_state.health = max(0, st.session_state.health - total_health_loss - 10)
|
| 651 |
+
# clamp after emergency shortfall + fee
|
| 652 |
+
loan.totalOwed = clamp_money(loan.totalOwed + shortfall + EMERGENCY_FEE)
|
| 653 |
+
loan.creditScore = max(300, loan.creditScore - 35)
|
| 654 |
+
st.toast(f"Couldn't afford mandatory expenses! Emergency loan: {fmt_money(shortfall + EMERGENCY_FEE)}, Health -{total_health_loss + 10}")
|
| 655 |
+
|
| 656 |
+
if st.session_state.currentLevel >= 3 and st.session_state.wallet < st.session_state.monthlyIncome * 0.5:
|
| 657 |
+
loss = int(((st.session_state.monthlyIncome * 0.5) - st.session_state.wallet) / jmd(1))
|
| 658 |
+
if loss > 0:
|
| 659 |
+
st.session_state.happiness = max(0, st.session_state.happiness - loss)
|
| 660 |
+
st.toast(f"Low funds affecting mood! Happiness -{loss}")
|
| 661 |
+
|
| 662 |
+
st.session_state.currentMonth += 1
|
| 663 |
+
st.session_state.currentDay = 1
|
| 664 |
+
st.session_state.roundsLeft -= 1
|
| 665 |
+
st.session_state.wallet += st.session_state.monthlyIncome
|
| 666 |
+
st.session_state.paidExpenses = []
|
| 667 |
+
st.session_state.hasWorkedThisMonth = False
|
| 668 |
+
|
| 669 |
+
if st.session_state.roundsLeft <= 0:
|
| 670 |
+
st.toast("Time's up! You ran out of rounds!")
|
| 671 |
+
st.session_state.gamePhase = "completed"
|
| 672 |
+
return
|
| 673 |
+
|
| 674 |
+
if loan.missedPayments > 0:
|
| 675 |
+
late_fee = LATE_FEE_BASE + (loan.missedPayments * LATE_FEE_PER_MISS)
|
| 676 |
+
# clamp after applying late fees
|
| 677 |
+
loan.totalOwed = clamp_money(loan.totalOwed + late_fee)
|
| 678 |
+
st.toast(f"Late fee applied: {fmt_money(late_fee)}")
|
| 679 |
+
loan.missedPayments = 0
|
| 680 |
+
|
| 681 |
+
# Extra month-end consequences if Utilities weren't paid
|
| 682 |
+
unpaid_ids = {e["id"] for e in unpaid}
|
| 683 |
+
if "utilities" in unpaid_ids:
|
| 684 |
+
# Credit score & happiness hit + reconnection fee
|
| 685 |
+
loan.creditScore = max(300, loan.creditScore - UTILITY_NONPAY_CS_HIT)
|
| 686 |
+
st.session_state.happiness = max(0, st.session_state.happiness - UTILITY_NONPAY_HAPPY_HIT)
|
| 687 |
+
# clamp after reconnect fee
|
| 688 |
+
loan.totalOwed = clamp_money(loan.totalOwed + UTILITY_RECONNECT_FEE)
|
| 689 |
+
st.toast(
|
| 690 |
+
f"Utilities unpaid: Credit -{UTILITY_NONPAY_CS_HIT}, "
|
| 691 |
+
f"Happiness -{UTILITY_NONPAY_HAPPY_HIT}, "
|
| 692 |
+
f"Reconnect fee {fmt_money(UTILITY_RECONNECT_FEE)}"
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
check_achievements()
|
| 696 |
+
|
| 697 |
+
if st.session_state.gamePhase == "repaying":
|
| 698 |
+
# integerize monthly interest and clamp new total
|
| 699 |
+
monthly_interest = clamp_money((loan.totalOwed * loan.interestRate) / 12.0)
|
| 700 |
+
loan.totalOwed = clamp_money(loan.totalOwed + monthly_interest)
|
| 701 |
+
# ensure completion triggers even after month-end math
|
| 702 |
+
check_loan_completion()
|
| 703 |
+
|
| 704 |
+
st.toast(f"Month {st.session_state.currentMonth}: +{fmt_money(st.session_state.monthlyIncome)} income. {st.session_state.roundsLeft} rounds left.")
|
| 705 |
+
check_end_conditions()
|
| 706 |
+
|
| 707 |
+
# ===============================
|
| 708 |
+
# UI
|
| 709 |
+
# ===============================
|
| 710 |
+
def header():
|
| 711 |
+
level = get_level(st.session_state.currentLevel)
|
| 712 |
+
base_payday_hint = "Paid at month end"
|
| 713 |
+
st.markdown(f"""
|
| 714 |
+
<div class="game-header">
|
| 715 |
+
<div class="game-title">🎮 Debt Dilemma 💳</div>
|
| 716 |
+
<h3>Month {st.session_state.currentMonth} · Day {st.session_state.currentDay}/{st.session_state.daysInMonth}</h3>
|
| 717 |
+
<p>Level {st.session_state.currentLevel}: {level.name}</p>
|
| 718 |
+
</div>
|
| 719 |
+
""", unsafe_allow_html=True)
|
| 720 |
+
|
| 721 |
+
st.markdown(f"""
|
| 722 |
+
<div class="metric-card">
|
| 723 |
+
<h3>📊 Your Status</h3>
|
| 724 |
+
<div style="display:flex;flex-wrap:wrap;gap:1rem;justify-content:space-between;margin-top:1rem;">
|
| 725 |
+
<div><strong>💰 Wallet:</strong> {fmt_money(st.session_state.wallet)}</div>
|
| 726 |
+
<div><strong>💼 Base Salary:</strong> {fmt_money(st.session_state.monthlyIncome)} <small>({base_payday_hint})</small></div>
|
| 727 |
+
<div><strong>📊 Credit:</strong> {st.session_state.loan.creditScore}</div>
|
| 728 |
+
<div><strong>❤️ Health:</strong> {st.session_state.health}%</div>
|
| 729 |
+
<div><strong>😊 Happy:</strong> {st.session_state.happiness}%</div>
|
| 730 |
+
</div>
|
| 731 |
+
<div style="margin-top:.5rem;">
|
| 732 |
+
<small>💡 “Work for Money” is extra: once per fortnight you can earn ~JMD$6k–9k for 1 day, and it lowers happiness by 10%.</small>
|
| 733 |
+
</div>
|
| 734 |
+
</div>
|
| 735 |
+
""", unsafe_allow_html=True)
|
| 736 |
+
|
| 737 |
+
def setup_screen():
|
| 738 |
+
level = get_level(st.session_state.currentLevel)
|
| 739 |
+
|
| 740 |
+
# --- Header ---
|
| 741 |
+
st.markdown(f"""
|
| 742 |
+
<div class="game-header">
|
| 743 |
+
<div class="game-title">🎯 Level {level.level}: {level.name}</div>
|
| 744 |
+
<p>{level.description}</p>
|
| 745 |
+
</div>
|
| 746 |
+
""", unsafe_allow_html=True)
|
| 747 |
+
|
| 748 |
+
# --- Loan Info ---
|
| 749 |
+
months_est, interest_est = payoff_projection(
|
| 750 |
+
balance=float(level.loanAmount),
|
| 751 |
+
apr=level.interestRate,
|
| 752 |
+
monthly_payment=level.monthlyPayment
|
| 753 |
+
)
|
| 754 |
+
if months_est is None:
|
| 755 |
+
proj_html = "<small>🧮 Projection: Payment too low — balance will grow.</small>"
|
| 756 |
+
else:
|
| 757 |
+
years_est = months_est / 12.0
|
| 758 |
+
proj_html = (f"<small>🧮 Projection: ~{months_est} payments (~{years_est:.1f} years), "
|
| 759 |
+
f"est. interest {fmt_money(interest_est)}</small>")
|
| 760 |
+
|
| 761 |
+
st.markdown("### 📋 Loan Details")
|
| 762 |
+
st.markdown(f"""
|
| 763 |
+
<div class="metric-card">
|
| 764 |
+
<h3>💳 Loan Information</h3>
|
| 765 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;">
|
| 766 |
+
<div><strong>💰 Amount:</strong> {fmt_money(level.loanAmount)}</div>
|
| 767 |
+
<div><strong>📈 Interest:</strong> {int(level.interestRate*100)}% yearly</div>
|
| 768 |
+
<div><strong>💳 Monthly Payment:</strong> {fmt_money(level.monthlyPayment)}</div>
|
| 769 |
+
<div><strong>⏰ Time Limit (target):</strong> {level.totalMonths} months</div>
|
| 770 |
+
</div>
|
| 771 |
+
<div style="margin-top:.5rem;">{proj_html}</div>
|
| 772 |
+
</div>
|
| 773 |
+
""", unsafe_allow_html=True)
|
| 774 |
+
|
| 775 |
+
# --- Player situation ---
|
| 776 |
+
st.markdown("### 🌟 Your Current Situation")
|
| 777 |
+
st.markdown(f"""
|
| 778 |
+
<div class="metric-card">
|
| 779 |
+
<h3>💼 Your Financial Status</h3>
|
| 780 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;">
|
| 781 |
+
<div><strong>💰 Wallet (random start):</strong> {fmt_money(st.session_state.wallet)}</div>
|
| 782 |
+
<div><strong>💼 Base Salary (month end):</strong> {fmt_money(st.session_state.monthlyIncome)}</div>
|
| 783 |
+
<div><strong>🏠 Required Expenses (per month):</strong> {fmt_money(required_expenses_total())}</div>
|
| 784 |
+
<div><strong>📊 Credit Score:</strong> {st.session_state.loan.creditScore}</div>
|
| 785 |
+
</div>
|
| 786 |
+
<div style="margin-top:.5rem;">
|
| 787 |
+
<small>💡 You get your <strong>base salary automatically at month end</strong>. The <em>Work for Money</em> button gives <strong>extra</strong> cash (~JMD$6k–9k) and can be used <strong>once per fortnight</strong> (costs 1 day, -10% happiness).</small>
|
| 788 |
+
</div>
|
| 789 |
+
</div>
|
| 790 |
+
""", unsafe_allow_html=True)
|
| 791 |
+
|
| 792 |
+
st.markdown("""
|
| 793 |
+
### 🎮 Game Rules - Let's Learn Together!
|
| 794 |
+
🎯 **Your Mission:** Pay off your loan while staying healthy and happy!
|
| 795 |
+
|
| 796 |
+
📚 **Important Rules:**
|
| 797 |
+
- 💰 Interest grows your debt each month - pay on time!
|
| 798 |
+
- ❤️ Health 0% = hospital visit (game over!)
|
| 799 |
+
- 😊 Happiness 0% = you give up (game over!)
|
| 800 |
+
- 🍎 Food keeps you healthy (+10 health when paid!)
|
| 801 |
+
- 🎮 Entertainment & 🍿 Snacks make you happy (+5% each!)
|
| 802 |
+
- 🎲 Random events happen daily - some good, some challenging!
|
| 803 |
+
|
| 804 |
+
⏰ **Time Costs:**
|
| 805 |
+
- 💼 Work (extra) = 1 day (**once per fortnight**)
|
| 806 |
+
- 💳 Make loan payment = 1 day
|
| 807 |
+
- 🎲 Handle events = 1 day
|
| 808 |
+
- 🏠 Paying expenses = FREE (no time cost!)
|
| 809 |
+
|
| 810 |
+
💡 **Payday:** Your **base salary** hits your wallet automatically at **month end**.
|
| 811 |
+
""")
|
| 812 |
+
|
| 813 |
+
# use st.buttondd (scoped) instead of st.button
|
| 814 |
+
st.buttondd(
|
| 815 |
+
f"🚀 Accept Level {level.level} Loan & {'Receive ' + fmt_money(level.loanAmount) if DISBURSE_LOAN_TO_WALLET else 'Start the Level'}!",
|
| 816 |
+
use_container_width=True,
|
| 817 |
+
on_click=start_loan,
|
| 818 |
+
key="btn_start_loan",
|
| 819 |
+
variant="success"
|
| 820 |
+
)
|
| 821 |
+
|
| 822 |
+
def main_screen():
|
| 823 |
+
header()
|
| 824 |
+
|
| 825 |
+
left, right = st.columns([2,1])
|
| 826 |
+
|
| 827 |
+
with left:
|
| 828 |
+
evt: Optional[RandomEvent] = st.session_state.currentEvent
|
| 829 |
+
if evt:
|
| 830 |
+
st.markdown(f"""
|
| 831 |
+
<div class="event-card">
|
| 832 |
+
<div class="event-title">{evt.icon} {evt.title}</div>
|
| 833 |
+
<p>{evt.description}</p>
|
| 834 |
+
</div>
|
| 835 |
+
""", unsafe_allow_html=True)
|
| 836 |
+
|
| 837 |
+
badge_colors = {
|
| 838 |
+
"opportunity": "🌟 GREAT OPPORTUNITY!",
|
| 839 |
+
"expense": "⚠️ EXPENSE ALERT",
|
| 840 |
+
"penalty": "⛔ CHALLENGE",
|
| 841 |
+
"bonus": "🎁 AWESOME BONUS!"
|
| 842 |
+
}
|
| 843 |
+
st.success(badge_colors[evt.type])
|
| 844 |
+
|
| 845 |
+
c1, c2 = st.columns(2)
|
| 846 |
+
if evt.choices:
|
| 847 |
+
with c1:
|
| 848 |
+
st.buttondd(evt.choices["accept"], use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_accept", variant="success")
|
| 849 |
+
with c2:
|
| 850 |
+
st.buttondd(evt.choices["decline"], use_container_width=True, on_click=lambda: handle_event_choice(False), key="evt_decline", variant="warning")
|
| 851 |
+
else:
|
| 852 |
+
st.buttondd("✨ Continue (uses 1 day)", use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_continue", variant="success")
|
| 853 |
+
|
| 854 |
+
st.markdown("---")
|
| 855 |
+
|
| 856 |
+
# --- Loan status + payoff projection ---
|
| 857 |
+
progress = progress_percent(st.session_state.loan.totalOwed, st.session_state.loan.monthlyPayment, st.session_state.loan.totalMonths)/100
|
| 858 |
+
months_est, interest_est = payoff_projection(
|
| 859 |
+
st.session_state.loan.totalOwed,
|
| 860 |
+
st.session_state.loan.interestRate,
|
| 861 |
+
st.session_state.loan.monthlyPayment
|
| 862 |
+
)
|
| 863 |
+
if months_est is None:
|
| 864 |
+
proj_html = "<div><strong>🧮 Projection:</strong> Payment too low — balance will grow.</div>"
|
| 865 |
+
else:
|
| 866 |
+
years_est = months_est / 12.0
|
| 867 |
+
proj_html = (
|
| 868 |
+
f"<div><strong>🧮 Projection:</strong> ~{months_est} payments "
|
| 869 |
+
f"(~{years_est:.1f} years), est. interest {fmt_money(interest_est)}</div>"
|
| 870 |
+
)
|
| 871 |
+
|
| 872 |
+
st.markdown(f"""
|
| 873 |
+
<div class="metric-card">
|
| 874 |
+
<h3>💳 Loan Status</h3>
|
| 875 |
+
<div style="margin-top: 1rem;">
|
| 876 |
+
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
| 877 |
+
<div><strong>💰 Still Owed:</strong> {fmt_money(st.session_state.loan.totalOwed)}</div>
|
| 878 |
+
<div><strong>💳 Monthly Due:</strong> {fmt_money(st.session_state.loan.monthlyPayment)}</div>
|
| 879 |
+
</div>
|
| 880 |
+
{proj_html}
|
| 881 |
+
<div style="margin: 1rem 0;">
|
| 882 |
+
<div style="background: #e0e0e0; border-radius: 10px; height: 20px; overflow: hidden;">
|
| 883 |
+
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); height: 100%; width: {progress*100}%; border-radius: 10px;"></div>
|
| 884 |
+
</div>
|
| 885 |
+
<div style="text-align: center; margin-top: 0.5rem;"><strong>🎯 Progress: {progress*100:.1f}% Complete!</strong></div>
|
| 886 |
+
</div>
|
| 887 |
+
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem;">
|
| 888 |
+
<div><strong>✅ Payments:</strong> {st.session_state.loan.monthsPaid}/{st.session_state.loan.totalMonths}</div>
|
| 889 |
+
<div><strong>{"⚠️" if st.session_state.loan.missedPayments > 0 else "✅"} Missed:</strong> {st.session_state.loan.missedPayments}</div>
|
| 890 |
+
<div><small>{(st.session_state.loan.interestRate*100/12):.1f}% monthly interest</small></div>
|
| 891 |
+
<div><small>📅 Paid This Month: {fmt_money(st.session_state.amountPaidThisMonth)}{" — ✅ Full" if st.session_state.fullPaymentMadeThisMonth else ""}</small></div>
|
| 892 |
+
</div>
|
| 893 |
+
</div>
|
| 894 |
+
</div>
|
| 895 |
+
""", unsafe_allow_html=True)
|
| 896 |
+
|
| 897 |
+
can_afford = st.session_state.wallet >= (st.session_state.loan.monthlyPayment + required_expenses_total())
|
| 898 |
+
# use ceil so you can actually clear small residuals
|
| 899 |
+
pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), math.ceil(st.session_state.loan.totalOwed)))
|
| 900 |
+
|
| 901 |
+
b1, b2, b3 = st.columns([2,2,2])
|
| 902 |
+
with b1:
|
| 903 |
+
st.buttondd(
|
| 904 |
+
f"💰 Full Payment (1 day) {fmt_money(st.session_state.loan.monthlyPayment)}",
|
| 905 |
+
disabled=not can_afford,
|
| 906 |
+
use_container_width=True,
|
| 907 |
+
on_click=do_make_payment_full,
|
| 908 |
+
key="btn_pay_full",
|
| 909 |
+
variant="success" if can_afford else "warning"
|
| 910 |
+
)
|
| 911 |
+
with b2:
|
| 912 |
+
st.buttondd(
|
| 913 |
+
f"💸 Pay What I Can (1 day) {fmt_money(pay_what)}",
|
| 914 |
+
disabled=pay_what<=0,
|
| 915 |
+
use_container_width=True,
|
| 916 |
+
on_click=do_make_payment_partial,
|
| 917 |
+
key="btn_pay_partial",
|
| 918 |
+
variant="success" if pay_what>0 else "warning"
|
| 919 |
+
)
|
| 920 |
+
with b3:
|
| 921 |
+
st.buttondd("⏭️ Skip Payment (1 day)", use_container_width=True, on_click=do_skip_payment, key="btn_skip", variant="danger")
|
| 922 |
+
|
| 923 |
+
st.markdown("### 🏠 Monthly Expenses (Free Actions - No Time Cost!)")
|
| 924 |
+
cols = st.columns(2)
|
| 925 |
+
for i, exp in enumerate(EXPENSES):
|
| 926 |
+
with cols[i % 2]:
|
| 927 |
+
required_text = "⚠️ Required" if exp["required"] else "🌟 Optional"
|
| 928 |
+
happiness_text = f"<br><small>😊 (+{exp.get('happinessBoost', 0)}% happiness)</small>" if exp.get('happinessBoost', 0) > 0 else ""
|
| 929 |
+
st.markdown(f"""
|
| 930 |
+
<div class="expense-card">
|
| 931 |
+
<h4>{exp['emoji']} {exp['name']} - {fmt_money(exp['amount'])}</h4>
|
| 932 |
+
<p>{required_text}{happiness_text}</p>
|
| 933 |
+
</div>
|
| 934 |
+
""", unsafe_allow_html=True)
|
| 935 |
+
disabled = st.session_state.wallet < exp["amount"]
|
| 936 |
+
st.buttondd(
|
| 937 |
+
f"{exp['emoji']} Pay",
|
| 938 |
+
key=f"pay_{exp['id']}",
|
| 939 |
+
disabled=disabled,
|
| 940 |
+
on_click=lambda e=exp: pay_expense(e),
|
| 941 |
+
use_container_width=True,
|
| 942 |
+
variant="success" if not disabled else "warning"
|
| 943 |
+
)
|
| 944 |
+
|
| 945 |
+
st.markdown("---")
|
| 946 |
+
label = "🌅 End Day & See What Happens!"
|
| 947 |
+
if st.session_state.currentDay == st.session_state.daysInMonth:
|
| 948 |
+
label = f"🗓️ End Month {st.session_state.currentMonth} → Payday: {fmt_money(st.session_state.monthlyIncome)}!"
|
| 949 |
+
st.buttondd(label, disabled=st.session_state.currentEvent is not None, use_container_width=True, on_click=lambda: advance_day(no_event=False), key="btn_end_day", variant="success")
|
| 950 |
+
st.buttondd("⏩ Skip to Month End (When Low on Money!)", use_container_width=True, on_click=fast_forward_to_month_end, key="btn_ff", variant="warning")
|
| 951 |
+
|
| 952 |
+
with right:
|
| 953 |
+
health_color = "🟢" if st.session_state.health > 70 else "🟡" if st.session_state.health > 30 else "🔴"
|
| 954 |
+
happiness_color = "😊" if st.session_state.happiness > 70 else "😐" if st.session_state.happiness > 30 else "😢"
|
| 955 |
+
|
| 956 |
+
net_monthly = st.session_state.monthlyIncome - required_expenses_total() - st.session_state.loan.monthlyPayment
|
| 957 |
+
net_color = "🟢" if net_monthly > 0 else "🔴"
|
| 958 |
+
|
| 959 |
+
st.markdown("### 🌟 Your Wellbeing")
|
| 960 |
+
st.markdown(f"""
|
| 961 |
+
<div class="metric-card">
|
| 962 |
+
<h3>💪 Status Overview</h3>
|
| 963 |
+
<div style="margin-top: 1rem;">
|
| 964 |
+
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
|
| 965 |
+
<div><strong>❤️ Health:</strong> {health_color} {st.session_state.health}%</div>
|
| 966 |
+
<div><strong>😊 Happiness:</strong> {happiness_color} {st.session_state.happiness}%</div>
|
| 967 |
+
</div>
|
| 968 |
+
<div style="text-align: center; padding: 1rem 0; border-top: 1px solid #eee;">
|
| 969 |
+
<strong>💹 Monthly Budget:</strong> {net_color} {fmt_money(net_monthly)}<br>
|
| 970 |
+
<small>After loan & required expenses</small>
|
| 971 |
+
</div>
|
| 972 |
+
</div>
|
| 973 |
+
</div>
|
| 974 |
+
""", unsafe_allow_html=True)
|
| 975 |
+
|
| 976 |
+
# Work button
|
| 977 |
+
work_available = can_work_this_period()
|
| 978 |
+
st.buttondd("💼 Work for Money! (1 day, once/fortnight)\n~JMD$6k–9k, -10% Happiness",
|
| 979 |
+
disabled=not work_available,
|
| 980 |
+
on_click=do_work_for_money,
|
| 981 |
+
key="btn_work",
|
| 982 |
+
variant="success" if work_available else "warning")
|
| 983 |
+
cur_fn = current_fortnight()
|
| 984 |
+
st.caption(f"📅 Fortnight {cur_fn}/2 — you can work once each 2 weeks!")
|
| 985 |
+
|
| 986 |
+
def reset_game():
|
| 987 |
+
# INTEGRATION: only reset the Debt Dilemma state; then rerun
|
| 988 |
+
for k in list(st.session_state.keys()):
|
| 989 |
+
# keep global app keys like 'user', 'current_page', 'current_game'
|
| 990 |
+
if k not in {"user", "current_page", "xp", "streak", "current_game", "temp_user"}:
|
| 991 |
+
del st.session_state[k]
|
| 992 |
+
init_state()
|
| 993 |
+
st.session_state.dd_start_ts = time.time() # fresh timer after reset
|
| 994 |
+
st.rerun()
|
| 995 |
+
|
| 996 |
+
def hospital_screen():
|
| 997 |
+
st.error("🏥 You've been hospitalized. Health hit 0%. Game over.")
|
| 998 |
+
# use scoped button
|
| 999 |
+
st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_hospital", variant="success")
|
| 1000 |
+
|
| 1001 |
+
def burnout_screen():
|
| 1002 |
+
st.warning("😵 You burned out. Happiness hit 0%. Game over.")
|
| 1003 |
+
# use scoped button
|
| 1004 |
+
st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_burnout", variant="success")
|
| 1005 |
+
|
| 1006 |
+
def level_complete_screen():
|
| 1007 |
+
_award_level_completion_if_needed()
|
| 1008 |
+
st.success(f"🎉 Level {st.session_state.currentLevel} complete!")
|
| 1009 |
+
def _go_next():
|
| 1010 |
+
st.session_state.currentLevel += 1
|
| 1011 |
+
lvl = get_level(st.session_state.currentLevel)
|
| 1012 |
+
st.session_state.loan = LoanDetails(
|
| 1013 |
+
principal=lvl.loanAmount,
|
| 1014 |
+
interestRate=lvl.interestRate,
|
| 1015 |
+
monthlyPayment=lvl.monthlyPayment,
|
| 1016 |
+
totalOwed=float(lvl.loanAmount),
|
| 1017 |
+
monthsPaid=0,
|
| 1018 |
+
totalMonths=lvl.totalMonths,
|
| 1019 |
+
missedPayments=0,
|
| 1020 |
+
creditScore=st.session_state.loan.creditScore,
|
| 1021 |
+
)
|
| 1022 |
+
st.session_state.monthlyIncome = lvl.startingIncome
|
| 1023 |
+
st.session_state.dd_start_ts = time.time() # reset timer for new level
|
| 1024 |
+
st.session_state.gamePhase = "setup"
|
| 1025 |
+
st.buttondd("➡️ Start next level", on_click=_go_next, key="btn_next_level", variant="success")
|
| 1026 |
+
|
| 1027 |
+
def completed_screen():
|
| 1028 |
+
_award_level_completion_if_needed()
|
| 1029 |
+
st.balloons()
|
| 1030 |
+
st.success("🏁 You’ve finished all levels or ran out of rounds!")
|
| 1031 |
+
# use scoped button
|
| 1032 |
+
st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_completed", variant="success")
|
| 1033 |
+
|
| 1034 |
+
# ===============================
|
| 1035 |
+
# Public entry point expected by game.py
|
| 1036 |
+
# ===============================
|
| 1037 |
+
def show_debt_dilemma():
|
| 1038 |
+
|
| 1039 |
+
load_css(os.path.join("assets", "styles.css"))
|
| 1040 |
+
|
| 1041 |
+
_ensure_dd_css()
|
| 1042 |
+
st.markdown(f'<div class="{DD_SCOPE_CLASS}">', unsafe_allow_html=True) # OPEN SCOPE
|
| 1043 |
+
|
| 1044 |
+
# Initialize game state
|
| 1045 |
+
init_state()
|
| 1046 |
+
|
| 1047 |
+
# Route within the game
|
| 1048 |
+
phase = st.session_state.gamePhase
|
| 1049 |
+
if phase == "setup":
|
| 1050 |
+
setup_screen()
|
| 1051 |
+
elif phase == "hospital":
|
| 1052 |
+
hospital_screen()
|
| 1053 |
+
elif phase == "burnout":
|
| 1054 |
+
burnout_screen()
|
| 1055 |
+
elif phase == "level-complete":
|
| 1056 |
+
level_complete_screen()
|
| 1057 |
+
elif phase == "completed":
|
| 1058 |
+
completed_screen()
|
| 1059 |
+
else:
|
| 1060 |
+
main_screen()
|
| 1061 |
+
|
| 1062 |
+
st.markdown('</div>', unsafe_allow_html=True) # CLOSE SCOPE
|
phase/Student_view/games/profitpuzzle.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, time
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from utils import api as backend # HTTP to backend Space
|
| 4 |
+
from utils import db as dbapi # direct DB path (only if DISABLE_DB=0)
|
| 5 |
+
|
| 6 |
+
DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
|
| 7 |
+
|
| 8 |
+
def _refresh_global_xp():
|
| 9 |
+
user = st.session_state.get("user")
|
| 10 |
+
if not user:
|
| 11 |
+
return
|
| 12 |
+
try:
|
| 13 |
+
if DISABLE_DB:
|
| 14 |
+
stats = backend.user_stats(user["user_id"])
|
| 15 |
+
else:
|
| 16 |
+
stats = dbapi.user_xp_and_level(user["user_id"])
|
| 17 |
+
st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
|
| 18 |
+
st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
|
| 19 |
+
except Exception as e:
|
| 20 |
+
st.warning(f"XP refresh failed: {e}")
|
| 21 |
+
|
| 22 |
+
# --- CSS Styling ---
|
| 23 |
+
def load_css():
|
| 24 |
+
st.markdown("""
|
| 25 |
+
<style>
|
| 26 |
+
/* Hide Streamlit default elements */
|
| 27 |
+
#MainMenu {visibility: hidden;}
|
| 28 |
+
footer {visibility: hidden;}
|
| 29 |
+
header {visibility: hidden;}
|
| 30 |
+
|
| 31 |
+
/* Main container styling */
|
| 32 |
+
.main .block-container {
|
| 33 |
+
padding-top: 2rem;
|
| 34 |
+
padding-bottom: 2rem;
|
| 35 |
+
font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Game header styling */
|
| 39 |
+
.game-header {
|
| 40 |
+
background: linear-gradient(135deg, #d946ef, #ec4899);
|
| 41 |
+
padding: 2rem;
|
| 42 |
+
border-radius: 15px;
|
| 43 |
+
color: white;
|
| 44 |
+
text-align: center;
|
| 45 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 46 |
+
margin-bottom: 2rem;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* Scenario card styling */
|
| 50 |
+
.scenario-card {
|
| 51 |
+
background: #ffffff;
|
| 52 |
+
padding: 2rem;
|
| 53 |
+
border-radius: 15px;
|
| 54 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 55 |
+
border: 2px solid #e5e7eb;
|
| 56 |
+
margin-bottom: 1.5rem;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/* Variables display */
|
| 60 |
+
.variables-card {
|
| 61 |
+
background: linear-gradient(to right, #4ade80, #22d3ee);
|
| 62 |
+
padding: 1.5rem;
|
| 63 |
+
border-radius: 12px;
|
| 64 |
+
color: white;
|
| 65 |
+
margin: 1rem 0;
|
| 66 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/* Progress card */
|
| 70 |
+
.progress-card {
|
| 71 |
+
background: #3b82f6;
|
| 72 |
+
padding: 1.5rem;
|
| 73 |
+
border-radius: 12px;
|
| 74 |
+
color: white;
|
| 75 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
| 76 |
+
margin-bottom: 1rem;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/* XP display */
|
| 80 |
+
.xp-display {
|
| 81 |
+
background: #10b981;
|
| 82 |
+
padding: 1rem;
|
| 83 |
+
border-radius: 12px;
|
| 84 |
+
color: white;
|
| 85 |
+
text-align: center;
|
| 86 |
+
font-weight: bold;
|
| 87 |
+
margin-bottom: 1rem;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Solution card */
|
| 91 |
+
.solution-card {
|
| 92 |
+
background: #f0f9ff;
|
| 93 |
+
padding: 1.5rem;
|
| 94 |
+
border-radius: 12px;
|
| 95 |
+
border: 2px solid #0ea5e9;
|
| 96 |
+
margin: 1rem 0;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* Custom button styling */
|
| 100 |
+
/* Default button styling (white buttons) */
|
| 101 |
+
.stButton > button {
|
| 102 |
+
background: #ffffff !important;
|
| 103 |
+
color: #111827 !important; /* dark gray text */
|
| 104 |
+
border: 2px solid #d1d5db !important;
|
| 105 |
+
border-radius: 12px !important;
|
| 106 |
+
padding: 0.75rem 1.5rem !important;
|
| 107 |
+
font-weight: bold !important;
|
| 108 |
+
font-size: 1.1rem !important;
|
| 109 |
+
transition: all 0.3s ease !important;
|
| 110 |
+
font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif !important;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.stButton > button:hover {
|
| 114 |
+
background: #f9fafb !important;
|
| 115 |
+
transform: translateY(-2px) !important;
|
| 116 |
+
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* Next button styling */
|
| 120 |
+
.next-btn button {
|
| 121 |
+
background: #3b82f6 !important;
|
| 122 |
+
color: white !important;
|
| 123 |
+
border: none !important;
|
| 124 |
+
}
|
| 125 |
+
.next-btn button:hover {
|
| 126 |
+
background: #2563eb !important;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* Restart button styling */
|
| 130 |
+
.restart-btn button {
|
| 131 |
+
background: #ec4899 !important;
|
| 132 |
+
color: white !important;
|
| 133 |
+
border: none !important;
|
| 134 |
+
}
|
| 135 |
+
.restart-btn button:hover {
|
| 136 |
+
background: #db2777 !important;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
/* Text input styling */
|
| 142 |
+
.stTextInput > div > div > input {
|
| 143 |
+
border-radius: 12px !important;
|
| 144 |
+
border: 2px solid #d1d5db !important;
|
| 145 |
+
padding: 12px 16px !important;
|
| 146 |
+
font-size: 1.1rem !important;
|
| 147 |
+
font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif !important;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.stTextInput > div > div > input:focus {
|
| 151 |
+
border-color: #10b981 !important;
|
| 152 |
+
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2) !important;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/* Slider styling */
|
| 156 |
+
.stSlider > div > div > div {
|
| 157 |
+
background: linear-gradient(to right, #4ade80, #22d3ee) !important;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* Sidebar styling */
|
| 161 |
+
.css-1d391kg {
|
| 162 |
+
background: #f8fafc;
|
| 163 |
+
border-radius: 12px;
|
| 164 |
+
padding: 1rem;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* Success/Error message styling */
|
| 168 |
+
.stSuccess {
|
| 169 |
+
background: #dcfce7 !important;
|
| 170 |
+
border: 2px solid #16a34a !important;
|
| 171 |
+
border-radius: 12px !important;
|
| 172 |
+
color: #15803d !important;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.stError {
|
| 176 |
+
background: #fef2f2 !important;
|
| 177 |
+
border: 2px solid #dc2626 !important;
|
| 178 |
+
border-radius: 12px !important;
|
| 179 |
+
color: #dc2626 !important;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.stInfo {
|
| 183 |
+
background: #eff6ff !important;
|
| 184 |
+
border: 2px solid #2563eb !important;
|
| 185 |
+
border-radius: 12px !important;
|
| 186 |
+
color: #1d4ed8 !important;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.stWarning {
|
| 190 |
+
background: #fffbeb !important;
|
| 191 |
+
border: 2px solid #d97706 !important;
|
| 192 |
+
border-radius: 12px !important;
|
| 193 |
+
color: #92400e !important;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
/* Difficulty badge styling */
|
| 197 |
+
.difficulty-easy {
|
| 198 |
+
background: #dcfce7;
|
| 199 |
+
color: #16a34a;
|
| 200 |
+
padding: 0.25rem 0.75rem;
|
| 201 |
+
border-radius: 12px;
|
| 202 |
+
font-weight: bold;
|
| 203 |
+
font-size: 0.9rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.difficulty-medium {
|
| 207 |
+
background: #fef3c7;
|
| 208 |
+
color: #d97706;
|
| 209 |
+
padding: 0.25rem 0.75rem;
|
| 210 |
+
border-radius: 12px;
|
| 211 |
+
font-weight: bold;
|
| 212 |
+
font-size: 0.9rem;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.difficulty-hard {
|
| 216 |
+
background: #fecaca;
|
| 217 |
+
color: #dc2626;
|
| 218 |
+
padding: 0.25rem 0.75rem;
|
| 219 |
+
border-radius: 12px;
|
| 220 |
+
font-weight: bold;
|
| 221 |
+
font-size: 0.9rem;
|
| 222 |
+
}
|
| 223 |
+
</style>
|
| 224 |
+
""", unsafe_allow_html=True)
|
| 225 |
+
|
| 226 |
+
#--- Show progress in sidebar ---
|
| 227 |
+
# --- Sidebar Progress ---
|
| 228 |
+
def show_profit_progress_sidebar():
|
| 229 |
+
scenarios = st.session_state.get("profit_scenarios", [])
|
| 230 |
+
total_scenarios = len(scenarios)
|
| 231 |
+
current_s = st.session_state.get("current_scenario", 0)
|
| 232 |
+
completed_count = len(st.session_state.get("completed_scenarios", []))
|
| 233 |
+
|
| 234 |
+
with st.sidebar:
|
| 235 |
+
#add sidebar details for eg
|
| 236 |
+
st.sidebar.markdown(f"""
|
| 237 |
+
<div class="xp-display">
|
| 238 |
+
<h2>🏆 Your Progress</h2>
|
| 239 |
+
<p style="font-size: 1.5rem;">Total XP: {st.session_state.get("score", 0)}</p>
|
| 240 |
+
</div>
|
| 241 |
+
""", unsafe_allow_html=True)
|
| 242 |
+
|
| 243 |
+
st.sidebar.markdown("### 🎯 Challenge List")
|
| 244 |
+
for i, s in enumerate(scenarios):
|
| 245 |
+
if i in st.session_state.completed_scenarios:
|
| 246 |
+
st.sidebar.success(f"✅ {s['title']}")
|
| 247 |
+
elif i == st.session_state.current_scenario:
|
| 248 |
+
st.sidebar.info(f"🎯 {s['title']} (Current)")
|
| 249 |
+
else:
|
| 250 |
+
st.sidebar.write(f"⭕ {s['title']}")
|
| 251 |
+
|
| 252 |
+
st.sidebar.markdown("""
|
| 253 |
+
<div style="background: #f0f9ff; padding: 1rem; border-radius: 12px; border: 2px solid #0ea5e9; margin-top: 1rem;">
|
| 254 |
+
<h3>🧮 Profit Formula</h3>
|
| 255 |
+
<p><strong>Profit = Revenue - Cost</strong></p>
|
| 256 |
+
<hr style="border-color: #0ea5e9;">
|
| 257 |
+
<p><strong>Revenue</strong> = Units × Selling Price</p>
|
| 258 |
+
<p><strong>Cost</strong> = Units × Cost per Unit</p>
|
| 259 |
+
</div>
|
| 260 |
+
""", unsafe_allow_html=True)
|
| 261 |
+
|
| 262 |
+
#space and back button
|
| 263 |
+
st.sidebar.markdown("<br>", unsafe_allow_html=True)
|
| 264 |
+
|
| 265 |
+
if st.button("← Back to Games Hub", use_container_width=True):
|
| 266 |
+
st.session_state.current_game = None
|
| 267 |
+
st.rerun()
|
| 268 |
+
|
| 269 |
+
def _current_scenario():
|
| 270 |
+
ps = st.session_state.get("profit_scenarios", [])
|
| 271 |
+
idx = st.session_state.get("current_scenario", 0)
|
| 272 |
+
return (ps[idx] if ps and 0 <= idx < len(ps) else None)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def next_scenario():
|
| 276 |
+
total = len(st.session_state.get("profit_scenarios", []))
|
| 277 |
+
if st.session_state.get("current_scenario", 0) < total - 1:
|
| 278 |
+
st.session_state.current_scenario += 1
|
| 279 |
+
st.session_state.user_answer = ""
|
| 280 |
+
st.session_state.show_solution = False
|
| 281 |
+
st.rerun()
|
| 282 |
+
|
| 283 |
+
def reset_game():
|
| 284 |
+
st.session_state.current_scenario = 0
|
| 285 |
+
st.session_state.user_answer = ""
|
| 286 |
+
st.session_state.show_solution = False
|
| 287 |
+
st.session_state.score = 0
|
| 288 |
+
st.session_state.completed_scenarios = []
|
| 289 |
+
st.session_state.pp_start_ts = time.time()
|
| 290 |
+
st.rerun()
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# --- Profit Puzzle Game ---
|
| 295 |
+
def show_profit_puzzle():
|
| 296 |
+
# Load CSS styling
|
| 297 |
+
load_css()
|
| 298 |
+
|
| 299 |
+
if "pp_start_ts" not in st.session_state:
|
| 300 |
+
st.session_state.pp_start_ts = time.time()
|
| 301 |
+
|
| 302 |
+
st.markdown("""
|
| 303 |
+
<div class="game-header">
|
| 304 |
+
<h1>🎯 Profit Puzzle Challenge!</h1>
|
| 305 |
+
<p>Learn to calculate profits while having fun! 🚀</p>
|
| 306 |
+
</div>
|
| 307 |
+
""", unsafe_allow_html=True)
|
| 308 |
+
|
| 309 |
+
# -------------------------
|
| 310 |
+
# Game State Management
|
| 311 |
+
# -------------------------
|
| 312 |
+
if "current_scenario" not in st.session_state:
|
| 313 |
+
st.session_state.current_scenario = 0
|
| 314 |
+
if "user_answer" not in st.session_state:
|
| 315 |
+
st.session_state.user_answer = ""
|
| 316 |
+
if "show_solution" not in st.session_state:
|
| 317 |
+
st.session_state.show_solution = False
|
| 318 |
+
if "score" not in st.session_state:
|
| 319 |
+
st.session_state.score = 0
|
| 320 |
+
if "completed_scenarios" not in st.session_state:
|
| 321 |
+
st.session_state.completed_scenarios = []
|
| 322 |
+
if "slider_units" not in st.session_state:
|
| 323 |
+
st.session_state.slider_units = 10
|
| 324 |
+
if "slider_price" not in st.session_state:
|
| 325 |
+
st.session_state.slider_price = 50
|
| 326 |
+
if "slider_cost" not in st.session_state:
|
| 327 |
+
st.session_state.slider_cost = 30
|
| 328 |
+
|
| 329 |
+
# -------------------------
|
| 330 |
+
# Scenario Setup
|
| 331 |
+
# -------------------------
|
| 332 |
+
scenarios = [
|
| 333 |
+
{
|
| 334 |
+
"id": "juice-stand",
|
| 335 |
+
"title": "🧃 Juice Stand Profit",
|
| 336 |
+
"description": "You sold juice at your school event. Calculate your profit!",
|
| 337 |
+
"variables": {"units": 10, "sellingPrice": 50, "costPerUnit": 30},
|
| 338 |
+
"difficulty": "easy",
|
| 339 |
+
"xpReward": 20
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"id": "craft-business",
|
| 343 |
+
"title": "🎨 Craft Business",
|
| 344 |
+
"description": "Your handmade crafts are selling well. What's your profit?",
|
| 345 |
+
"variables": {"units": 15, "sellingPrice": 80, "costPerUnit": 45},
|
| 346 |
+
"difficulty": "medium",
|
| 347 |
+
"xpReward": 20
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"id": "bake-sale",
|
| 351 |
+
"title": "🧁 School Bake Sale",
|
| 352 |
+
"description": "You organized a bake sale fundraiser. Calculate the profit!",
|
| 353 |
+
"variables": {"units": 25, "sellingPrice": 60, "costPerUnit": 35},
|
| 354 |
+
"difficulty": "medium",
|
| 355 |
+
"xpReward": 20
|
| 356 |
+
},
|
| 357 |
+
{
|
| 358 |
+
"id": "tutoring-service",
|
| 359 |
+
"title": "📚 Tutoring Service",
|
| 360 |
+
"description": "You've been tutoring younger students. What's your profit after expenses?",
|
| 361 |
+
"variables": {"units": 8, "sellingPrice": 200, "costPerUnit": 50},
|
| 362 |
+
"difficulty": "hard",
|
| 363 |
+
"xpReward": 40
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"id": "dynamic-scenario",
|
| 367 |
+
"title": "🎮 Custom Business Scenario",
|
| 368 |
+
"description": "Use the sliders to create your own business scenario and calculate profit!",
|
| 369 |
+
"variables": {"units": st.session_state.slider_units,
|
| 370 |
+
"sellingPrice": st.session_state.slider_price,
|
| 371 |
+
"costPerUnit": st.session_state.slider_cost},
|
| 372 |
+
"difficulty": "medium",
|
| 373 |
+
"xpReward": 50
|
| 374 |
+
}
|
| 375 |
+
]
|
| 376 |
+
|
| 377 |
+
# after scenarios = [...]
|
| 378 |
+
st.session_state.profit_scenarios = scenarios # Store scenarios in session state for sidebar access
|
| 379 |
+
scenario = scenarios[st.session_state.current_scenario]
|
| 380 |
+
is_dynamic = scenario["id"] == "dynamic-scenario"
|
| 381 |
+
|
| 382 |
+
# -------------------------
|
| 383 |
+
# Helper Functions
|
| 384 |
+
# -------------------------
|
| 385 |
+
def calculate_profit(units, price, cost):
|
| 386 |
+
return units * (price - cost)
|
| 387 |
+
|
| 388 |
+
def check_answer():
|
| 389 |
+
try:
|
| 390 |
+
user_val = float(st.session_state.user_answer)
|
| 391 |
+
except ValueError:
|
| 392 |
+
st.warning("Please enter a number.")
|
| 393 |
+
return
|
| 394 |
+
|
| 395 |
+
units = int(scenario["variables"]["units"])
|
| 396 |
+
price = int(scenario["variables"]["sellingPrice"])
|
| 397 |
+
cost = int(scenario["variables"]["costPerUnit"])
|
| 398 |
+
actual_profit = units * (price - cost)
|
| 399 |
+
|
| 400 |
+
correct = abs(user_val - actual_profit) < 0.01
|
| 401 |
+
reward = int(scenario.get("xpReward", 20)) if correct else 0
|
| 402 |
+
|
| 403 |
+
# UI feedback
|
| 404 |
+
if correct:
|
| 405 |
+
st.success(f"✅ Awesome! You got it right! +{reward} XP 🎉")
|
| 406 |
+
st.session_state.score += reward
|
| 407 |
+
if st.session_state.current_scenario not in st.session_state.completed_scenarios:
|
| 408 |
+
st.session_state.completed_scenarios.append(st.session_state.current_scenario)
|
| 409 |
+
else:
|
| 410 |
+
st.error(f"❌ Oops. Correct profit is JA${actual_profit:.2f}")
|
| 411 |
+
|
| 412 |
+
# Persist to TiDB if logged in
|
| 413 |
+
user = st.session_state.get("user")
|
| 414 |
+
if user:
|
| 415 |
+
elapsed_ms = int((time.time() - st.session_state.get("pp_start_ts", time.time())) * 1000)
|
| 416 |
+
|
| 417 |
+
try:
|
| 418 |
+
if DISABLE_DB:
|
| 419 |
+
# Route to backend Space
|
| 420 |
+
backend.record_profit_puzzler_play(
|
| 421 |
+
user_id=user["user_id"],
|
| 422 |
+
puzzles_solved=1 if correct else 0,
|
| 423 |
+
mistakes=0 if correct else 1,
|
| 424 |
+
elapsed_ms=elapsed_ms,
|
| 425 |
+
gained_xp=reward # keep UI and server in sync
|
| 426 |
+
)
|
| 427 |
+
else:
|
| 428 |
+
# Direct DB path if you keep it
|
| 429 |
+
if hasattr(dbapi, "record_profit_puzzler_play"):
|
| 430 |
+
dbapi.record_profit_puzzler_play(
|
| 431 |
+
user_id=user["user_id"],
|
| 432 |
+
puzzles_solved=1 if correct else 0,
|
| 433 |
+
mistakes=0 if correct else 1,
|
| 434 |
+
elapsed_ms=elapsed_ms,
|
| 435 |
+
gained_xp=reward
|
| 436 |
+
)
|
| 437 |
+
else:
|
| 438 |
+
# Fallback to your existing detailed writer
|
| 439 |
+
dbapi.record_profit_puzzle_result(
|
| 440 |
+
user_id=user["user_id"],
|
| 441 |
+
scenario_id=scenario.get("id") or f"scenario_{st.session_state.current_scenario}",
|
| 442 |
+
title=scenario.get("title", f"Scenario {st.session_state.current_scenario+1}"),
|
| 443 |
+
units=int(scenario["variables"]["units"]),
|
| 444 |
+
price=int(scenario["variables"]["sellingPrice"]),
|
| 445 |
+
cost=int(scenario["variables"]["costPerUnit"]),
|
| 446 |
+
user_answer=float(st.session_state.user_answer),
|
| 447 |
+
actual_profit=float(actual_profit),
|
| 448 |
+
is_correct=bool(correct),
|
| 449 |
+
gained_xp=int(reward)
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
_refresh_global_xp()
|
| 453 |
+
except Exception as e:
|
| 454 |
+
st.warning(f"Save failed: {e}")
|
| 455 |
+
else:
|
| 456 |
+
st.info("Login to earn and save XP.")
|
| 457 |
+
|
| 458 |
+
st.session_state.show_solution = True
|
| 459 |
+
|
| 460 |
+
def next_scenario():
|
| 461 |
+
if st.session_state.get("current_scenario", 0) < len(st.session_state.get("profit_scenarios", [])) - 1:
|
| 462 |
+
st.session_state.current_scenario += 1
|
| 463 |
+
st.session_state.user_answer = ""
|
| 464 |
+
st.session_state.show_solution = False
|
| 465 |
+
st.session_state.pp_start_ts = time.time()
|
| 466 |
+
st.rerun()
|
| 467 |
+
|
| 468 |
+
def reset_game():
|
| 469 |
+
st.session_state.current_scenario = 0
|
| 470 |
+
st.session_state.user_answer = ""
|
| 471 |
+
st.session_state.show_solution = False
|
| 472 |
+
st.session_state.score = 0
|
| 473 |
+
st.session_state.completed_scenarios = []
|
| 474 |
+
|
| 475 |
+
# -------------------------
|
| 476 |
+
# UI Layout
|
| 477 |
+
# -------------------------
|
| 478 |
+
|
| 479 |
+
difficulty_class = f"difficulty-{scenario['difficulty']}"
|
| 480 |
+
st.markdown(f"""
|
| 481 |
+
<div class="scenario-card">
|
| 482 |
+
<h2>{scenario['title']}</h2>
|
| 483 |
+
<span class="{difficulty_class}">{scenario['difficulty'].upper()}</span>
|
| 484 |
+
<p style="margin-top: 1rem; font-size: 1.1rem;">{scenario["description"]}</p>
|
| 485 |
+
<h3>📊 Business Details</h3>
|
| 486 |
+
<p><strong>Units Sold:</strong> {scenario['variables']['units']}</p>
|
| 487 |
+
<p><strong>Selling Price per Unit:</strong> JA${scenario['variables']['sellingPrice']}</p>
|
| 488 |
+
<p><strong>Cost per Unit:</strong> JA${scenario['variables']['costPerUnit']}</p>
|
| 489 |
+
</div>
|
| 490 |
+
""", unsafe_allow_html=True)
|
| 491 |
+
|
| 492 |
+
if is_dynamic:
|
| 493 |
+
st.markdown("### 🎛️ Customize Your Business")
|
| 494 |
+
st.session_state.slider_units = st.slider("Units Sold", 1, 50, st.session_state.slider_units)
|
| 495 |
+
st.session_state.slider_price = st.slider("Selling Price per Unit (JA$)", 10, 200, st.session_state.slider_price, 5)
|
| 496 |
+
st.session_state.slider_cost = st.slider("Cost per Unit (JA$)", 5, st.session_state.slider_price - 1, st.session_state.slider_cost, 5)
|
| 497 |
+
|
| 498 |
+
scenario["variables"] = {
|
| 499 |
+
"units": st.session_state.slider_units,
|
| 500 |
+
"sellingPrice": st.session_state.slider_price,
|
| 501 |
+
"costPerUnit": st.session_state.slider_cost
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
st.markdown("### 💰 What's the profit?")
|
| 506 |
+
st.text_input("Enter Profit (JA$):", key="user_answer", disabled=st.session_state.show_solution, placeholder="Type your answer here...")
|
| 507 |
+
|
| 508 |
+
if not st.session_state.show_solution:
|
| 509 |
+
st.button("🎯 Check My Answer!", on_click=check_answer)
|
| 510 |
+
else:
|
| 511 |
+
actual_profit = calculate_profit(
|
| 512 |
+
scenario["variables"]["units"],
|
| 513 |
+
scenario["variables"]["sellingPrice"],
|
| 514 |
+
scenario["variables"]["costPerUnit"]
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
st.markdown(f"""
|
| 518 |
+
<div class="solution-card">
|
| 519 |
+
<h3>🧮 Solution Breakdown</h3>
|
| 520 |
+
<p><strong>Revenue:</strong> {scenario['variables']['units']} × JA${scenario['variables']['sellingPrice']} = JA${scenario['variables']['units'] * scenario['variables']['sellingPrice']}</p>
|
| 521 |
+
<p><strong>Total Cost:</strong> {scenario['variables']['units']} × JA${scenario['variables']['costPerUnit']} = JA${scenario['variables']['units'] * scenario['variables']['costPerUnit']}</p>
|
| 522 |
+
<p><strong>Profit:</strong> JA${scenario['variables']['units'] * scenario['variables']['sellingPrice']} - JA${scenario['variables']['units'] * scenario['variables']['costPerUnit']} = <span style="color: #10b981; font-weight: bold; font-size: 1.2rem;">JA${actual_profit}</span></p>
|
| 523 |
+
</div>
|
| 524 |
+
""", unsafe_allow_html=True)
|
| 525 |
+
|
| 526 |
+
next_col, restart_col = st.columns(2)
|
| 527 |
+
with next_col:
|
| 528 |
+
if st.session_state.current_scenario < len(scenarios) - 1:
|
| 529 |
+
st.markdown('<div class="next-btn">', unsafe_allow_html=True)
|
| 530 |
+
st.button("➡️ Next Challenge", on_click=next_scenario)
|
| 531 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 532 |
+
with restart_col:
|
| 533 |
+
st.markdown('<div class="restart-btn">', unsafe_allow_html=True)
|
| 534 |
+
st.button("🔄 Start Over", on_click=reset_game)
|
| 535 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 536 |
+
|
phase/Student_view/lesson.py
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
from typing import List, Dict, Any, Optional, Tuple
|
| 3 |
+
import re
|
| 4 |
+
import datetime # NEW
|
| 5 |
+
|
| 6 |
+
# Internal API client (already used across the app)
|
| 7 |
+
# Uses BACKEND_URL/BACKEND_TOKEN env vars and has retry logic
|
| 8 |
+
# See utils/api.py for details
|
| 9 |
+
from utils import api as backend_api
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
FALLBACK_TAG = "<!--fallback-->"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------
|
| 16 |
+
# Page state helpers
|
| 17 |
+
# ---------------------------------------------
|
| 18 |
+
_SS_DEFAULTS = {
|
| 19 |
+
"level": "beginner", # beginner | intermediate | advanced
|
| 20 |
+
"module_id": None, # int (1-based)
|
| 21 |
+
"topic_idx": 0, # 0-based within module
|
| 22 |
+
"mode": "catalog", # catalog | lesson | quiz | results
|
| 23 |
+
"topics_cache": {}, # {(level, module_id): [(title, text), ...]}
|
| 24 |
+
"quiz_data": None, # original quiz payload (list[dict])
|
| 25 |
+
"quiz_answers": {}, # q_index -> "A"|"B"|"C"|"D"
|
| 26 |
+
"quiz_result": None, # backend result dict
|
| 27 |
+
"chatbot_feedback": None, # str
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _ensure_state():
|
| 32 |
+
for k, v in _SS_DEFAULTS.items():
|
| 33 |
+
if k not in st.session_state:
|
| 34 |
+
st.session_state[k] = v
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------
|
| 38 |
+
# Content metadata (UI only)
|
| 39 |
+
# ---------------------------------------------
|
| 40 |
+
# These titles mirror the React version you shared, so the experience feels the same.
|
| 41 |
+
MODULES_META: Dict[str, List[Dict[str, Any]]] = {
|
| 42 |
+
"beginner": [
|
| 43 |
+
{
|
| 44 |
+
"id": 1,
|
| 45 |
+
"title": "Understanding Money",
|
| 46 |
+
"description": "Learn the basics of what money is, its uses, and how to manage it.",
|
| 47 |
+
"duration": "20 min",
|
| 48 |
+
"completed": False,
|
| 49 |
+
"locked": False,
|
| 50 |
+
"difficulty": "Easy",
|
| 51 |
+
"topics": [
|
| 52 |
+
"What is Money?",
|
| 53 |
+
"Needs vs. Wants",
|
| 54 |
+
"Earning Money",
|
| 55 |
+
"Saving Money",
|
| 56 |
+
"Spending Wisely",
|
| 57 |
+
"Play: Money Match",
|
| 58 |
+
"Quiz",
|
| 59 |
+
"Summary: My Money Plan"
|
| 60 |
+
]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": 2,
|
| 64 |
+
"title": "Basic Budgeting",
|
| 65 |
+
"description": "Start building the habit of planning and managing money through budgeting.",
|
| 66 |
+
"duration": "20 min",
|
| 67 |
+
"completed": False,
|
| 68 |
+
"locked": False,
|
| 69 |
+
"difficulty": "Easy",
|
| 70 |
+
"topics": [
|
| 71 |
+
"What is a Budget?",
|
| 72 |
+
"Income and Expenses",
|
| 73 |
+
"Profit and Loss",
|
| 74 |
+
"Saving Goals",
|
| 75 |
+
"Making Choices",
|
| 76 |
+
"Play: Budget Builder",
|
| 77 |
+
"Quiz",
|
| 78 |
+
"Summary: My First Budget"
|
| 79 |
+
]
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"id": 3,
|
| 83 |
+
"title": "Money in Action",
|
| 84 |
+
"description": "Learn how money is used in everyday transactions and its role in society.",
|
| 85 |
+
"duration": "20 min",
|
| 86 |
+
"completed": False,
|
| 87 |
+
"locked": False,
|
| 88 |
+
"difficulty": "Easy",
|
| 89 |
+
"topics": [
|
| 90 |
+
"Paying for Things",
|
| 91 |
+
"Keeping Track of Money",
|
| 92 |
+
"What Are Taxes?",
|
| 93 |
+
"Giving and Sharing",
|
| 94 |
+
"Money Safety",
|
| 95 |
+
"Play: Piggy Bank Challenge",
|
| 96 |
+
"Quiz",
|
| 97 |
+
"Summary: Money Journal"
|
| 98 |
+
]
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"id": 4,
|
| 102 |
+
"title": "Simple Business Ideas",
|
| 103 |
+
"description": "Explore the basics of starting a small business and earning profit.",
|
| 104 |
+
"duration": "20 min",
|
| 105 |
+
"completed": False,
|
| 106 |
+
"locked": False,
|
| 107 |
+
"difficulty": "Easy",
|
| 108 |
+
"topics": [
|
| 109 |
+
"What is a Business?",
|
| 110 |
+
"Costs in a Business",
|
| 111 |
+
"Revenue in a Business",
|
| 112 |
+
"Profit in a Business",
|
| 113 |
+
"Advertising Basics",
|
| 114 |
+
"Play: Smart Shopper",
|
| 115 |
+
"Quiz",
|
| 116 |
+
"Summary: My Business Plan"
|
| 117 |
+
]
|
| 118 |
+
}
|
| 119 |
+
],
|
| 120 |
+
"intermediate": [],
|
| 121 |
+
"advanced": [],
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# Helper to read topic titles regardless of whether metadata uses `topics` or `topic_labels`
|
| 126 |
+
|
| 127 |
+
def _topic_plan(level: str, module_id: int):
|
| 128 |
+
"""
|
| 129 |
+
Returns a list of (title, backend_ordinal) after filtering:
|
| 130 |
+
- drop any 'Play:' topic
|
| 131 |
+
- drop 'Quiz'
|
| 132 |
+
- keep first five + the Summary (6 total)
|
| 133 |
+
backend_ordinal is the 1-based index in the original metadata (so backend files line up).
|
| 134 |
+
"""
|
| 135 |
+
mod = next(m for m in MODULES_META[level] if m["id"] == module_id)
|
| 136 |
+
raw = (mod.get("topics") or mod.get("topic_labels") or [])
|
| 137 |
+
plan = []
|
| 138 |
+
for i, t in enumerate(raw, start=1):
|
| 139 |
+
tl = t.strip().lower()
|
| 140 |
+
if tl == "quiz" or tl.startswith("play:"):
|
| 141 |
+
continue
|
| 142 |
+
plan.append((t, i))
|
| 143 |
+
|
| 144 |
+
# Ensure at most 6 topics: first five + Summary if present
|
| 145 |
+
if len(plan) > 6:
|
| 146 |
+
# Prefer keeping a 'Summary' entry last if it exists
|
| 147 |
+
summary_pos = next((idx for idx, (title, _) in enumerate(plan)
|
| 148 |
+
if title.strip().lower().startswith("summary")), None)
|
| 149 |
+
if summary_pos is not None:
|
| 150 |
+
plan = plan[:5] + [plan[summary_pos]]
|
| 151 |
+
else:
|
| 152 |
+
plan = plan[:6]
|
| 153 |
+
return plan
|
| 154 |
+
|
| 155 |
+
def _topic_titles(level: str, module_id: int):
|
| 156 |
+
return [t for (t, _) in _topic_plan(level, module_id)]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ---------------------------------------------
|
| 160 |
+
# Backend integrations
|
| 161 |
+
# ---------------------------------------------
|
| 162 |
+
@st.cache_data(show_spinner=False, ttl=300)
|
| 163 |
+
def _fetch_topic_from_backend(level: str, module_id: int, topic_idx: int) -> Tuple[str, str]:
|
| 164 |
+
"""
|
| 165 |
+
Returns (ui_title, content). Tries backend first, never crashes the UI.
|
| 166 |
+
Backend expects folders like: /app/lessons/lesson_{module_id}/topic_{ordinal}.txt
|
| 167 |
+
"""
|
| 168 |
+
plan = _topic_plan(level, module_id)
|
| 169 |
+
ui_title, backend_ordinal = plan[topic_idx] # 1-based in original list
|
| 170 |
+
|
| 171 |
+
payload = {
|
| 172 |
+
"lesson": f"lesson_{module_id}", # folder name
|
| 173 |
+
"module": str(module_id), # kept for compatibility
|
| 174 |
+
"topic": str(backend_ordinal), # 1-based ordinal mapping
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
data = {}
|
| 178 |
+
try:
|
| 179 |
+
# Try your documented route, then a simpler alias
|
| 180 |
+
data = backend_api._try_candidates(
|
| 181 |
+
"POST",
|
| 182 |
+
[
|
| 183 |
+
("/agents/lesson", {"json": payload}),
|
| 184 |
+
("/lesson", {"json": payload}),
|
| 185 |
+
],
|
| 186 |
+
) or {}
|
| 187 |
+
except Exception as e:
|
| 188 |
+
# Log once, keep the UI moving
|
| 189 |
+
st.warning(f"Lesson fetch failed for lesson_{module_id}/topic_{backend_ordinal}: {e}")
|
| 190 |
+
data = {}
|
| 191 |
+
|
| 192 |
+
# Accept several possible shapes
|
| 193 |
+
content = ""
|
| 194 |
+
for k in ("lesson_content", "content", "text", "body"):
|
| 195 |
+
v = data.get(k)
|
| 196 |
+
if isinstance(v, str) and v.strip():
|
| 197 |
+
content = v.strip()
|
| 198 |
+
break
|
| 199 |
+
if isinstance(v, dict):
|
| 200 |
+
vv = v.get("content") or v.get("text") or v.get("body")
|
| 201 |
+
if isinstance(vv, str) and vv.strip():
|
| 202 |
+
content = vv.strip()
|
| 203 |
+
break
|
| 204 |
+
|
| 205 |
+
return ui_title, content
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _extract_takeaways(text: str, max_items: int = 5) -> List[str]:
|
| 209 |
+
"""Heuristic key-takeaway extractor from raw lesson text."""
|
| 210 |
+
if not text:
|
| 211 |
+
return []
|
| 212 |
+
|
| 213 |
+
# Prefer explicit sections
|
| 214 |
+
m = re.search(r"(?mi)^\s*(Key\s*Takeaways?|Summary)\s*[:\n]+(.*)$", text, re.DOTALL)
|
| 215 |
+
if m:
|
| 216 |
+
body = m.group(2)
|
| 217 |
+
lines = [ln.strip(" •-*–\t") for ln in body.splitlines() if ln.strip()]
|
| 218 |
+
items = [ln for ln in lines if len(ln) > 3][:max_items]
|
| 219 |
+
if items:
|
| 220 |
+
return items
|
| 221 |
+
|
| 222 |
+
# Otherwise, harvest bullet-y looking lines
|
| 223 |
+
bullets = [
|
| 224 |
+
ln.strip(" •-*–\t")
|
| 225 |
+
for ln in text.splitlines()
|
| 226 |
+
if ln.strip().startswith(("-", "•", "*", "–")) and len(ln.strip()) > 3
|
| 227 |
+
]
|
| 228 |
+
if bullets:
|
| 229 |
+
return bullets[:max_items]
|
| 230 |
+
|
| 231 |
+
# Fallback: first few sentences
|
| 232 |
+
sents = re.split(r"(?<=[.!?])\s+", text.strip())
|
| 233 |
+
return [s for s in sents if len(s) > 20][:min(max_items, 3)]
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _start_quiz(level: str, module_id: int) -> Optional[List[Dict[str, Any]]]:
|
| 237 |
+
"""Ask backend to generate a 5-question mini quiz for this module."""
|
| 238 |
+
module_conf = next(m for m in MODULES_META[level] if m["id"] == module_id)
|
| 239 |
+
try:
|
| 240 |
+
quiz = backend_api.generate_quiz(
|
| 241 |
+
lesson_id=module_id, # int id works; backend uses it for retrieval bucketing
|
| 242 |
+
level_slug=level, # "beginner" | "intermediate" | "advanced"
|
| 243 |
+
lesson_title=module_conf["title"],
|
| 244 |
+
)
|
| 245 |
+
if isinstance(quiz, list) and quiz:
|
| 246 |
+
return quiz
|
| 247 |
+
return None
|
| 248 |
+
except Exception as e:
|
| 249 |
+
st.error(f"Could not generate quiz: {e}")
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _submit_quiz(level: str, module_id: int, original_quiz: List[Dict[str, Any]], answers_map: Dict[int, str]) -> Optional[Dict[str, Any]]:
|
| 254 |
+
"""Submit answers and get score + tutor feedback."""
|
| 255 |
+
user_answers = []
|
| 256 |
+
for i, q in enumerate(original_quiz):
|
| 257 |
+
# Expect letters A-D; default to ""
|
| 258 |
+
user_answers.append({
|
| 259 |
+
"question": q.get("question", f"Q{i+1}"),
|
| 260 |
+
"answer": answers_map.get(i, ""),
|
| 261 |
+
})
|
| 262 |
+
try:
|
| 263 |
+
result = backend_api.submit_quiz(
|
| 264 |
+
lesson_id=module_id,
|
| 265 |
+
level_slug=level,
|
| 266 |
+
user_answers=user_answers,
|
| 267 |
+
original_quiz=original_quiz,
|
| 268 |
+
)
|
| 269 |
+
return result
|
| 270 |
+
except Exception as e:
|
| 271 |
+
st.error(f"Could not submit quiz: {e}")
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
def _send_quiz_summary_to_chatbot(result: Dict[str, Any]):
|
| 275 |
+
"""
|
| 276 |
+
Send a concise, actionable summary of the quiz outcome to the chatbot,
|
| 277 |
+
then navigate to the Chatbot page with the conversation pre-seeded.
|
| 278 |
+
"""
|
| 279 |
+
level = st.session_state.level
|
| 280 |
+
module_id = st.session_state.module_id
|
| 281 |
+
mod = next(m for m in MODULES_META[level] if m["id"] == module_id)
|
| 282 |
+
|
| 283 |
+
score = result.get("score", {})
|
| 284 |
+
correct = int(score.get("correct", 0))
|
| 285 |
+
total = int(score.get("total", 0))
|
| 286 |
+
feedback = (result.get("feedback") or st.session_state.get("chatbot_feedback") or "").strip()
|
| 287 |
+
|
| 288 |
+
user_prompt = (
|
| 289 |
+
f"I just finished the quiz for '{mod['title']}' (module {module_id}) "
|
| 290 |
+
f"and scored {correct}/{total}. Please give me 2–3 targeted tips and 1 tiny action "
|
| 291 |
+
f"to improve before the next lesson. If there were wrong answers, explain them simply.\n\n"
|
| 292 |
+
f"Context from grader:\n{feedback}"
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
try:
|
| 296 |
+
# Hit your FastAPI chatbot route (HF under the hood)
|
| 297 |
+
resp = backend_api.send_to_chatbot([
|
| 298 |
+
{"role": "system", "content": "You are a friendly financial tutor for Jamaican students."},
|
| 299 |
+
{"role": "user", "content": user_prompt}
|
| 300 |
+
])
|
| 301 |
+
bot_reply = (resp or {}).get("reply", "").strip()
|
| 302 |
+
except Exception as e:
|
| 303 |
+
bot_reply = f"(Chatbot unavailable) Based on your result: {feedback or 'Nice work!'}"
|
| 304 |
+
|
| 305 |
+
# Seed the Chatbot page's message list so the conversation is visible immediately
|
| 306 |
+
msgs = st.session_state.get("messages") or [{
|
| 307 |
+
"id": "1",
|
| 308 |
+
"text": "Hi! I'm your AI Financial Tutor. What would you like to learn today?",
|
| 309 |
+
"sender": "assistant",
|
| 310 |
+
"timestamp": datetime.datetime.now(),
|
| 311 |
+
}]
|
| 312 |
+
msgs.append({"text": user_prompt, "sender": "user", "timestamp": datetime.datetime.now()})
|
| 313 |
+
msgs.append({"text": bot_reply, "sender": "assistant", "timestamp": datetime.datetime.now()})
|
| 314 |
+
st.session_state.messages = msgs
|
| 315 |
+
|
| 316 |
+
# Jump straight to Chatbot page
|
| 317 |
+
st.session_state.current_page = "Chatbot"
|
| 318 |
+
|
| 319 |
+
def _fallback_text(title: str, module_id: int, topic_ordinal: int) -> str:
|
| 320 |
+
"""
|
| 321 |
+
Minimal on-brand copy if the backend has not supplied a topic file yet.
|
| 322 |
+
Tailored to beginner modules so the UI stays useful.
|
| 323 |
+
"""
|
| 324 |
+
t = title.strip().lower()
|
| 325 |
+
if "what is money" in t or "money" == t:
|
| 326 |
+
return ("Money is a tool we use to trade for goods and services. "
|
| 327 |
+
"In Jamaica we use JMD coins and notes, and many people also pay digitally. "
|
| 328 |
+
"You can spend, save, or share money, but it is limited, so plan how you use it.") + "\n" + FALLBACK_TAG
|
| 329 |
+
if "need" in t and "want" in t:
|
| 330 |
+
return ("Needs keep you safe and healthy, like food, clothes, and school supplies. "
|
| 331 |
+
"Wants are nice to have, like toys or snacks. Cover needs first, then plan for wants.") + "\n" + FALLBACK_TAG
|
| 332 |
+
if "earn" in t:
|
| 333 |
+
return ("You earn money by doing work or providing value. Small jobs add up. "
|
| 334 |
+
"Earnings give you choices to spend, save, or share, and teach the value of effort.") + "\n" + FALLBACK_TAG
|
| 335 |
+
if "sav" in t:
|
| 336 |
+
return ("Saving means putting aside some money now for future needs or goals. "
|
| 337 |
+
"Start small and be consistent. A jar, a partner plan, or a bank account all help.") + "\n" + FALLBACK_TAG
|
| 338 |
+
if "spend" in t or "wisely" in t:
|
| 339 |
+
return ("Spending wisely means comparing prices, making a simple budget, and avoiding impulse buys. "
|
| 340 |
+
"Aim for best value so your goals stay on track.") + "\n" + FALLBACK_TAG
|
| 341 |
+
if "summary" in t or "journal" in t or "plan" in t:
|
| 342 |
+
return ("Quick recap: cover needs first, set a small saving goal, and make one spending rule for the week. "
|
| 343 |
+
"Write one action you will try before the next lesson.") + "\n" + FALLBACK_TAG
|
| 344 |
+
if "quiz" in t:
|
| 345 |
+
return ("Take a short quiz to check your understanding of this lesson.") + "\n" + FALLBACK_TAG
|
| 346 |
+
# Generic fallback
|
| 347 |
+
return (f"This unit will be populated from lesson_{module_id}/topic_{topic_ordinal}.txt. "
|
| 348 |
+
"For now, review the key idea and write one example from daily life.") + "\n" + FALLBACK_TAG
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# ---------------------------------------------
|
| 352 |
+
# UI building blocks
|
| 353 |
+
# ---------------------------------------------
|
| 354 |
+
def _render_catalog():
|
| 355 |
+
st.header("Financial Education")
|
| 356 |
+
st.caption("Build your financial knowledge with structured paths for every skill level.")
|
| 357 |
+
|
| 358 |
+
level = st.session_state.get("level", _SS_DEFAULTS["level"])
|
| 359 |
+
|
| 360 |
+
cols = st.columns(3)
|
| 361 |
+
for i, mod in enumerate(MODULES_META[level]):
|
| 362 |
+
with cols[i % 3]:
|
| 363 |
+
st.subheader(mod["title"])
|
| 364 |
+
if mod.get("description"):
|
| 365 |
+
st.caption(mod["description"])
|
| 366 |
+
st.caption(f"Duration: {mod.get('duration','—')} · Difficulty: {mod.get('difficulty','—')}")
|
| 367 |
+
with st.expander("Topics include"):
|
| 368 |
+
for t, _ord in _topic_plan(level, mod["id"]):
|
| 369 |
+
st.write("• ", t)
|
| 370 |
+
if st.button("Start Learning", key=f"start_{level}_{mod['id']}"):
|
| 371 |
+
# nuke stale topic cache for this module and any cached fetch failures
|
| 372 |
+
st.session_state.topics_cache.pop((level, mod["id"]), None)
|
| 373 |
+
try:
|
| 374 |
+
st.cache_data.clear()
|
| 375 |
+
except Exception:
|
| 376 |
+
pass
|
| 377 |
+
|
| 378 |
+
st.session_state.module_id = mod["id"]
|
| 379 |
+
st.session_state.topic_idx = 0
|
| 380 |
+
st.session_state.mode = "lesson"
|
| 381 |
+
st.rerun()
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def _render_lesson():
|
| 386 |
+
level = st.session_state.level
|
| 387 |
+
module_id = st.session_state.module_id
|
| 388 |
+
if module_id is None:
|
| 389 |
+
st.session_state.mode = "catalog"
|
| 390 |
+
st.rerun()
|
| 391 |
+
|
| 392 |
+
mod = next(m for m in MODULES_META[level] if m["id"] == module_id)
|
| 393 |
+
|
| 394 |
+
st.markdown(f"### {mod['title']}")
|
| 395 |
+
if mod.get("description"):
|
| 396 |
+
st.caption(mod["description"])
|
| 397 |
+
|
| 398 |
+
topics = _get_topics(level, module_id)
|
| 399 |
+
|
| 400 |
+
if not topics:
|
| 401 |
+
# build topics from metadata and use fallbacks so the page is never blank
|
| 402 |
+
plan = _topic_plan(level, module_id)
|
| 403 |
+
topics = [(title, _fallback_text(title, module_id, i + 1)) for i, (title, _) in enumerate(plan)]
|
| 404 |
+
st.session_state.topics_cache[(level, module_id)] = topics
|
| 405 |
+
|
| 406 |
+
with st.container(border=True):
|
| 407 |
+
progress = (st.session_state.topic_idx + 1) / max(1, len(topics))
|
| 408 |
+
st.progress(progress, text=f"Unit {st.session_state.topic_idx + 1} of {len(topics)}")
|
| 409 |
+
|
| 410 |
+
t_title, t_text = topics[st.session_state.topic_idx]
|
| 411 |
+
|
| 412 |
+
# Special Quiz placeholder
|
| 413 |
+
if t_title.strip().lower() == "quiz":
|
| 414 |
+
with st.spinner("Generating quiz…"):
|
| 415 |
+
quiz = _start_quiz(level, module_id)
|
| 416 |
+
if quiz:
|
| 417 |
+
st.session_state.quiz_data = quiz
|
| 418 |
+
st.session_state.quiz_answers = {}
|
| 419 |
+
st.session_state.mode = "quiz"
|
| 420 |
+
st.rerun()
|
| 421 |
+
else:
|
| 422 |
+
st.error("Quiz could not be generated. Please try again or skip.")
|
| 423 |
+
return
|
| 424 |
+
|
| 425 |
+
st.subheader(t_title)
|
| 426 |
+
source_note = "Default" if FALLBACK_TAG in t_text else "Backend"
|
| 427 |
+
st.caption(f"Source: {source_note}")
|
| 428 |
+
if t_text:
|
| 429 |
+
# strip the marker and render HTML from backend files (e.g., <b>Money</b>)
|
| 430 |
+
cleaned = t_text.replace(FALLBACK_TAG, "")
|
| 431 |
+
st.markdown(cleaned, unsafe_allow_html=True)
|
| 432 |
+
|
| 433 |
+
takeaways = _extract_takeaways(cleaned)
|
| 434 |
+
if takeaways:
|
| 435 |
+
st.markdown("#### Key Takeaways")
|
| 436 |
+
for it in takeaways:
|
| 437 |
+
st.write("✅ ", it)
|
| 438 |
+
else:
|
| 439 |
+
st.info("Content coming soon.")
|
| 440 |
+
|
| 441 |
+
col1, col2, col3 = st.columns([1, 1, 1])
|
| 442 |
+
with col1:
|
| 443 |
+
if st.button("← Previous", disabled=st.session_state.topic_idx == 0):
|
| 444 |
+
st.session_state.topic_idx -= 1
|
| 445 |
+
st.rerun()
|
| 446 |
+
with col2:
|
| 447 |
+
if st.button("Back to Modules"):
|
| 448 |
+
st.session_state.mode = "catalog"
|
| 449 |
+
st.session_state.module_id = None
|
| 450 |
+
st.rerun()
|
| 451 |
+
with col3:
|
| 452 |
+
is_last = st.session_state.topic_idx >= len(topics) - 1
|
| 453 |
+
|
| 454 |
+
if is_last:
|
| 455 |
+
# NEW: auto-start quiz when learner reaches the last topic
|
| 456 |
+
if not st.session_state.get("_auto_quiz_started", False):
|
| 457 |
+
st.session_state["_auto_quiz_started"] = True
|
| 458 |
+
with st.spinner("Generating quiz…"):
|
| 459 |
+
quiz = _start_quiz(level, module_id)
|
| 460 |
+
if quiz:
|
| 461 |
+
st.session_state.quiz_data = quiz
|
| 462 |
+
st.session_state.quiz_answers = {}
|
| 463 |
+
st.session_state.mode = "quiz"
|
| 464 |
+
st.rerun()
|
| 465 |
+
else:
|
| 466 |
+
st.error("Quiz could not be generated. Please try again.")
|
| 467 |
+
else:
|
| 468 |
+
# Fallback button if auto-start once failed
|
| 469 |
+
if st.button("Take Lesson Quiz →"):
|
| 470 |
+
with st.spinner("Generating quiz…"):
|
| 471 |
+
quiz = _start_quiz(level, module_id)
|
| 472 |
+
if quiz:
|
| 473 |
+
st.session_state.quiz_data = quiz
|
| 474 |
+
st.session_state.quiz_answers = {}
|
| 475 |
+
st.session_state.mode = "quiz"
|
| 476 |
+
st.rerun()
|
| 477 |
+
else:
|
| 478 |
+
st.error("Quiz could not be generated. Please try again.")
|
| 479 |
+
else:
|
| 480 |
+
if st.button("Next →"):
|
| 481 |
+
st.session_state.topic_idx += 1
|
| 482 |
+
st.rerun()
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
with st.expander("Module Units", expanded=False):
|
| 486 |
+
for i, (tt, _) in enumerate(topics):
|
| 487 |
+
label = f"{i+1}. {tt}"
|
| 488 |
+
st.button(label, key=f"jump_{i}", on_click=lambda j=i: st.session_state.update({"topic_idx": j}) or st.rerun())
|
| 489 |
+
|
| 490 |
+
def _get_topics(level: str, module_id: int) -> List[Tuple[str, str]]:
|
| 491 |
+
"""
|
| 492 |
+
Build the six-topic plan from metadata titles, try backend for each,
|
| 493 |
+
and if content is missing, provide a short fallback paragraph.
|
| 494 |
+
"""
|
| 495 |
+
cache_key = (level, module_id)
|
| 496 |
+
if cache_key in st.session_state.topics_cache:
|
| 497 |
+
return st.session_state.topics_cache[cache_key]
|
| 498 |
+
|
| 499 |
+
plan = _topic_plan(level, module_id) # six titles max
|
| 500 |
+
out: List[Tuple[str, str]] = []
|
| 501 |
+
|
| 502 |
+
for idx in range(len(plan)):
|
| 503 |
+
title, content = _fetch_topic_from_backend(level, module_id, idx)
|
| 504 |
+
if not content:
|
| 505 |
+
content = _fallback_text(title, module_id, idx + 1)
|
| 506 |
+
out.append((title, content))
|
| 507 |
+
|
| 508 |
+
st.session_state.topics_cache[cache_key] = out
|
| 509 |
+
return out
|
| 510 |
+
|
| 511 |
+
def _letter_for(i: int) -> str:
|
| 512 |
+
return chr(ord("A") + i)
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def _render_quiz():
|
| 516 |
+
quiz: List[Dict[str, Any]] = st.session_state.quiz_data or []
|
| 517 |
+
if not quiz:
|
| 518 |
+
st.session_state.mode = "lesson"
|
| 519 |
+
st.rerun()
|
| 520 |
+
|
| 521 |
+
st.markdown("### Lesson Quiz")
|
| 522 |
+
|
| 523 |
+
# Render each question as a block (single page quiz)
|
| 524 |
+
for q_idx, q in enumerate(quiz):
|
| 525 |
+
st.markdown(f"**Q{q_idx+1}. {q.get('question','').strip()}**")
|
| 526 |
+
opts = q.get("options") or []
|
| 527 |
+
# Build labels like "A. option"
|
| 528 |
+
labels = [f"{_letter_for(i)}. {opt}" for i, opt in enumerate(opts)]
|
| 529 |
+
|
| 530 |
+
def _on_select():
|
| 531 |
+
sel = st.session_state[f"ans_{q_idx}"] # "A. option text"
|
| 532 |
+
letter = sel.split(".", 1)[0] if isinstance(sel, str) else ""
|
| 533 |
+
st.session_state.quiz_answers[q_idx] = letter # store "A".."D"
|
| 534 |
+
|
| 535 |
+
# Preselect previously chosen letter, if any
|
| 536 |
+
saved_letter = st.session_state.quiz_answers.get(q_idx) # "A"
|
| 537 |
+
pre_idx = next((i for i, l in enumerate(labels) if saved_letter and l.startswith(f"{saved_letter}.")), None)
|
| 538 |
+
|
| 539 |
+
st.radio(
|
| 540 |
+
"",
|
| 541 |
+
labels,
|
| 542 |
+
index=pre_idx, # None means no default
|
| 543 |
+
key=f"ans_{q_idx}",
|
| 544 |
+
on_change=_on_select,
|
| 545 |
+
)
|
| 546 |
+
st.divider()
|
| 547 |
+
|
| 548 |
+
# Submit
|
| 549 |
+
all_answered = len(st.session_state.quiz_answers) == len(quiz)
|
| 550 |
+
if st.button("Submit Quiz", disabled=not all_answered):
|
| 551 |
+
with st.spinner("Grading…"):
|
| 552 |
+
result = _submit_quiz(
|
| 553 |
+
st.session_state.level,
|
| 554 |
+
st.session_state.module_id,
|
| 555 |
+
quiz,
|
| 556 |
+
st.session_state.quiz_answers,
|
| 557 |
+
)
|
| 558 |
+
if result:
|
| 559 |
+
st.session_state.quiz_result = result
|
| 560 |
+
st.session_state.chatbot_feedback = result.get("feedback")
|
| 561 |
+
# NEW: immediately send to chatbot and navigate there
|
| 562 |
+
_send_quiz_summary_to_chatbot(result)
|
| 563 |
+
st.rerun()
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def _render_results():
|
| 568 |
+
result = st.session_state.quiz_result or {}
|
| 569 |
+
score = result.get("score", {})
|
| 570 |
+
correct = score.get("correct", 0)
|
| 571 |
+
total = score.get("total", 0)
|
| 572 |
+
|
| 573 |
+
st.success(f"Quiz Complete! You scored {correct} / {total}.")
|
| 574 |
+
|
| 575 |
+
wrong = result.get("wrong", [])
|
| 576 |
+
if wrong:
|
| 577 |
+
with st.expander("Review your answers"):
|
| 578 |
+
for w in wrong:
|
| 579 |
+
st.markdown(f"**{w.get('question','')}**")
|
| 580 |
+
st.write(f"Your answer: {w.get('your_answer','')}")
|
| 581 |
+
st.write(f"Correct answer: {w.get('correct_answer','')}")
|
| 582 |
+
st.divider()
|
| 583 |
+
|
| 584 |
+
fb = st.session_state.chatbot_feedback
|
| 585 |
+
if fb:
|
| 586 |
+
st.markdown("#### Tutor Explanation")
|
| 587 |
+
st.write(fb)
|
| 588 |
+
|
| 589 |
+
level = st.session_state.level
|
| 590 |
+
module_id = st.session_state.module_id
|
| 591 |
+
planned = next((m.get("topics", []) for m in MODULES_META[level] if m["id"] == module_id), [])
|
| 592 |
+
try:
|
| 593 |
+
quiz_index = [t.strip().lower() for t in planned].index("quiz")
|
| 594 |
+
except ValueError:
|
| 595 |
+
quiz_index = None
|
| 596 |
+
|
| 597 |
+
c1, c2, c3 = st.columns([1, 1, 1])
|
| 598 |
+
with c1:
|
| 599 |
+
if st.button("Back to Modules"):
|
| 600 |
+
st.session_state.mode = "catalog"
|
| 601 |
+
st.session_state.module_id = None
|
| 602 |
+
st.rerun()
|
| 603 |
+
with c2:
|
| 604 |
+
if st.button("Ask the Chatbot →"):
|
| 605 |
+
st.session_state.current_page = "Chatbot"
|
| 606 |
+
st.session_state.chatbot_prefill = fb
|
| 607 |
+
st.rerun()
|
| 608 |
+
with c3:
|
| 609 |
+
if quiz_index is not None and quiz_index + 1 < len(planned):
|
| 610 |
+
if st.button("Continue Lesson →"):
|
| 611 |
+
st.session_state.mode = "lesson"
|
| 612 |
+
st.session_state.topic_idx = quiz_index + 1
|
| 613 |
+
st.rerun()
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
# ---------------------------------------------
|
| 617 |
+
# Public entry point(s)
|
| 618 |
+
# ---------------------------------------------
|
| 619 |
+
|
| 620 |
+
def render():
|
| 621 |
+
_ensure_state()
|
| 622 |
+
|
| 623 |
+
# Breadcrumb
|
| 624 |
+
st.caption("Learning Path · " + st.session_state.level.capitalize())
|
| 625 |
+
|
| 626 |
+
mode = st.session_state.mode
|
| 627 |
+
if mode == "catalog":
|
| 628 |
+
_render_catalog()
|
| 629 |
+
elif mode == "lesson":
|
| 630 |
+
_render_lesson()
|
| 631 |
+
elif mode == "quiz":
|
| 632 |
+
_render_quiz()
|
| 633 |
+
elif mode == "results":
|
| 634 |
+
_render_results()
|
| 635 |
+
else:
|
| 636 |
+
st.session_state.mode = "catalog"
|
| 637 |
+
_render_catalog()
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
# Some parts of the app import pages and call a conventional `show()`
|
| 641 |
+
show_page = render
|
| 642 |
+
|
| 643 |
+
|
| 644 |
+
if __name__ == "__main__":
|
| 645 |
+
# Allow standalone run for local testing
|
| 646 |
+
st.set_page_config(page_title="Lesson", page_icon="📘", layout="centered")
|
| 647 |
+
render()
|
| 648 |
+
|
| 649 |
+
#comment
|
phase/Student_view/practice_quiz.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
#added a practice_quiz.py (for the general practice quiz code could go here and the lesson_quiz code stuff in quiz.py)
|
phase/Student_view/quiz.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import streamlit as st
|
| 3 |
+
from utils.quizdata import quizzes_data
|
| 4 |
+
import datetime
|
| 5 |
+
import json
|
| 6 |
+
from utils import db as dbapi
|
| 7 |
+
import utils.api as api
|
| 8 |
+
|
| 9 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 10 |
+
|
| 11 |
+
def _get_quiz_from_source(quiz_id: int):
|
| 12 |
+
"""
|
| 13 |
+
Fetch a quiz payload from the local DB (if enabled) or from the backend API.
|
| 14 |
+
Expected backend shape: {'quiz': {...}, 'items': [...]}
|
| 15 |
+
"""
|
| 16 |
+
if USE_LOCAL_DB and hasattr(dbapi, "get_quiz"):
|
| 17 |
+
return dbapi.get_quiz(quiz_id)
|
| 18 |
+
# backend: expose GET /quizzes/{quiz_id}
|
| 19 |
+
return api.get_quiz(quiz_id)
|
| 20 |
+
|
| 21 |
+
def _submit_quiz_result(student_id: int, assignment_id: int, quiz_id: int,
|
| 22 |
+
score: int, total: int, details: dict):
|
| 23 |
+
"""
|
| 24 |
+
Submit a quiz result either to the local DB or to the backend API.
|
| 25 |
+
"""
|
| 26 |
+
if USE_LOCAL_DB and hasattr(dbapi, "submit_quiz"):
|
| 27 |
+
return dbapi.submit_quiz(student_id=student_id,
|
| 28 |
+
assignment_id=assignment_id,
|
| 29 |
+
quiz_id=quiz_id,
|
| 30 |
+
score=score, total=total, details=details)
|
| 31 |
+
# backend: POST /quizzes/submit (or your route of choice)
|
| 32 |
+
# utils.api should wrap that route; below assumes api.submit_quiz exists.
|
| 33 |
+
return api.submit_quiz(student_id=student_id,
|
| 34 |
+
assignment_id=assignment_id,
|
| 35 |
+
quiz_id=quiz_id,
|
| 36 |
+
score=score, total=total, details=details)
|
| 37 |
+
|
| 38 |
+
def _load_quiz_obj(quiz_id):
|
| 39 |
+
"""
|
| 40 |
+
Return a normalized quiz object from either quizzes_data (built-in)
|
| 41 |
+
or the backend/DB. Normalized shape:
|
| 42 |
+
{"title": str, "questions": [{"question","options","answer","points"}...]}
|
| 43 |
+
"""
|
| 44 |
+
# Built-ins first
|
| 45 |
+
if quiz_id in quizzes_data:
|
| 46 |
+
q = quizzes_data[quiz_id]
|
| 47 |
+
for qq in q.get("questions", []):
|
| 48 |
+
qq.setdefault("points", 1)
|
| 49 |
+
return q
|
| 50 |
+
|
| 51 |
+
# Teacher-assigned (DB/backend)
|
| 52 |
+
data = _get_quiz_from_source(int(quiz_id)) # <-- uses API when DISABLE_DB=1
|
| 53 |
+
if not data:
|
| 54 |
+
return {"title": f"Quiz {quiz_id}", "questions": []}
|
| 55 |
+
|
| 56 |
+
items_out = []
|
| 57 |
+
for it in (data.get("items") or []):
|
| 58 |
+
opts = it.get("options")
|
| 59 |
+
if isinstance(opts, (str, bytes)):
|
| 60 |
+
try:
|
| 61 |
+
opts = json.loads(opts)
|
| 62 |
+
except Exception:
|
| 63 |
+
opts = []
|
| 64 |
+
opts = opts or []
|
| 65 |
+
|
| 66 |
+
ans = it.get("answer_key")
|
| 67 |
+
if isinstance(ans, (str, bytes)):
|
| 68 |
+
try:
|
| 69 |
+
ans = json.loads(ans) # support '["A","C"]'
|
| 70 |
+
except Exception:
|
| 71 |
+
pass # allow "A"
|
| 72 |
+
|
| 73 |
+
def letter_to_text(letter):
|
| 74 |
+
if isinstance(letter, str):
|
| 75 |
+
idx = ord(letter.upper()) - 65
|
| 76 |
+
return opts[idx] if 0 <= idx < len(opts) else letter
|
| 77 |
+
return letter
|
| 78 |
+
|
| 79 |
+
if isinstance(ans, list):
|
| 80 |
+
ans_text = [letter_to_text(a) for a in ans]
|
| 81 |
+
else:
|
| 82 |
+
ans_text = letter_to_text(ans)
|
| 83 |
+
|
| 84 |
+
items_out.append({
|
| 85 |
+
"question": it.get("question", ""),
|
| 86 |
+
"options": opts,
|
| 87 |
+
"answer": ans_text, # text or list of texts
|
| 88 |
+
"points": int(it.get("points", 1)),
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
title = (data.get("quiz") or {}).get("title", f"Quiz {quiz_id}")
|
| 92 |
+
return {"title": title, "questions": items_out}
|
| 93 |
+
|
| 94 |
+
def _letter_to_index(ch: str) -> int:
|
| 95 |
+
return ord(ch.upper()) - 65 # 'A'->0, 'B'->1, ...
|
| 96 |
+
|
| 97 |
+
def _correct_to_indices(correct, options: list[str]):
|
| 98 |
+
"""
|
| 99 |
+
Map 'correct' (letters like 'A' or ['A','C'] OR option text(s)) -> list of indices.
|
| 100 |
+
"""
|
| 101 |
+
idxs = []
|
| 102 |
+
if isinstance(correct, list):
|
| 103 |
+
for c in correct:
|
| 104 |
+
if isinstance(c, str):
|
| 105 |
+
if len(c) == 1 and c.isalpha():
|
| 106 |
+
idxs.append(_letter_to_index(c))
|
| 107 |
+
elif c in options:
|
| 108 |
+
idxs.append(options.index(c))
|
| 109 |
+
elif isinstance(correct, str):
|
| 110 |
+
if len(correct) == 1 and correct.isalpha():
|
| 111 |
+
idxs.append(_letter_to_index(correct))
|
| 112 |
+
elif correct in options:
|
| 113 |
+
idxs.append(options.index(correct))
|
| 114 |
+
# keep only valid unique indices
|
| 115 |
+
return sorted({i for i in idxs if 0 <= i < len(options)})
|
| 116 |
+
|
| 117 |
+
def _normalize_user_to_indices(user_answer, options: list[str]):
|
| 118 |
+
"""
|
| 119 |
+
user_answer can be option text (or list of texts), or letters; return indices.
|
| 120 |
+
"""
|
| 121 |
+
idxs = []
|
| 122 |
+
if isinstance(user_answer, list):
|
| 123 |
+
for a in user_answer:
|
| 124 |
+
if isinstance(a, str):
|
| 125 |
+
if a in options:
|
| 126 |
+
idxs.append(options.index(a))
|
| 127 |
+
elif len(a) == 1 and a.isalpha():
|
| 128 |
+
idxs.append(_letter_to_index(a))
|
| 129 |
+
elif isinstance(user_answer, str):
|
| 130 |
+
if user_answer in options:
|
| 131 |
+
idxs.append(options.index(user_answer))
|
| 132 |
+
elif len(user_answer) == 1 and user_answer.isalpha():
|
| 133 |
+
idxs.append(_letter_to_index(user_answer))
|
| 134 |
+
return sorted([i for i in idxs if 0 <= i < len(options)])
|
| 135 |
+
|
| 136 |
+
# --- Helper for level styling ---
|
| 137 |
+
def get_level_style(level):
|
| 138 |
+
if level.lower() == "beginner":
|
| 139 |
+
return ("#28a745", "Beginner") # Green
|
| 140 |
+
elif level.lower() == "intermediate":
|
| 141 |
+
return ("#ffc107", "Intermediate") # Yellow
|
| 142 |
+
elif level.lower() == "advanced":
|
| 143 |
+
return ("#dc3545", "Advanced") # Red
|
| 144 |
+
else:
|
| 145 |
+
return ("#6c757d", level)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# --- Sidebar Progress ---
|
| 149 |
+
def show_quiz_progress_sidebar(quiz_id):
|
| 150 |
+
qobj = _load_quiz_obj(quiz_id)
|
| 151 |
+
total_q = max(1, len(qobj.get("questions", [])))
|
| 152 |
+
current_q = int(st.session_state.get("current_q", 0))
|
| 153 |
+
answered_count = len(st.session_state.get("answers", {}))
|
| 154 |
+
|
| 155 |
+
with st.sidebar:
|
| 156 |
+
st.markdown("""
|
| 157 |
+
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
|
| 158 |
+
<h3 style="margin: 0; color: #333;">Quiz Progress</h3>
|
| 159 |
+
<div style="font-size: 18px;">☰</div>
|
| 160 |
+
</div>
|
| 161 |
+
""", unsafe_allow_html=True)
|
| 162 |
+
|
| 163 |
+
st.markdown(f"""
|
| 164 |
+
<div style="margin-bottom: 15px;">
|
| 165 |
+
<strong style="color: #333; font-size: 14px;">{qobj.get('title','Quiz')}</strong>
|
| 166 |
+
</div>
|
| 167 |
+
""", unsafe_allow_html=True)
|
| 168 |
+
|
| 169 |
+
progress_value = (current_q) / total_q if current_q < total_q else 1.0
|
| 170 |
+
st.progress(progress_value)
|
| 171 |
+
|
| 172 |
+
st.markdown(f"""
|
| 173 |
+
<div style="text-align: center; margin: 10px 0; font-weight: bold; color: #333;">
|
| 174 |
+
{min(current_q + 1, total_q)} of {total_q}
|
| 175 |
+
</div>
|
| 176 |
+
""", unsafe_allow_html=True)
|
| 177 |
+
|
| 178 |
+
cols = st.columns(5)
|
| 179 |
+
for i in range(total_q):
|
| 180 |
+
col = cols[i % 5]
|
| 181 |
+
with col:
|
| 182 |
+
if i == current_q and current_q < total_q:
|
| 183 |
+
st.markdown(f"""
|
| 184 |
+
<div style="background-color: #28a745; color: white; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; font-weight: bold; font-size: 14px;">
|
| 185 |
+
{i + 1}
|
| 186 |
+
</div>
|
| 187 |
+
""", unsafe_allow_html=True)
|
| 188 |
+
elif i in st.session_state.get("answers", {}):
|
| 189 |
+
st.markdown(f"""
|
| 190 |
+
<div style="background-color: #d4edda; color: #155724; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; font-size: 14px;">
|
| 191 |
+
{i + 1}
|
| 192 |
+
</div>
|
| 193 |
+
""", unsafe_allow_html=True)
|
| 194 |
+
else:
|
| 195 |
+
st.markdown(f"""
|
| 196 |
+
<div style="background-color: #f8f9fa; color: #6c757d; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; border: 1px solid #dee2e6; font-size: 14px;">
|
| 197 |
+
{i + 1}
|
| 198 |
+
</div>
|
| 199 |
+
""", unsafe_allow_html=True)
|
| 200 |
+
|
| 201 |
+
st.markdown(f"""
|
| 202 |
+
<div style="font-size: 12px; color: #666; margin: 15px 0;">
|
| 203 |
+
<div style="margin: 5px 0;">
|
| 204 |
+
<span style="display: inline-block; width: 12px; height: 12px; background-color: #28a745; border-radius: 50%; margin-right: 8px;"></span>
|
| 205 |
+
<span>Answered ({answered_count})</span>
|
| 206 |
+
</div>
|
| 207 |
+
<div style="margin: 5px 0;">
|
| 208 |
+
<span style="display: inline-block; width: 12px; height: 12px; background-color: #17a2b8; border-radius: 50%; margin-right: 8px;"></span>
|
| 209 |
+
<span>Current</span>
|
| 210 |
+
</div>
|
| 211 |
+
<div style="margin: 5px 0;">
|
| 212 |
+
<span style="display: inline-block; width: 12px; height: 12px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 50%; margin-right: 8px;"></span>
|
| 213 |
+
<span>Not answered</span>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
""", unsafe_allow_html=True)
|
| 217 |
+
|
| 218 |
+
if st.button("← Back to Quizzes", use_container_width=True):
|
| 219 |
+
st.session_state.selected_quiz = None
|
| 220 |
+
st.rerun()
|
| 221 |
+
|
| 222 |
+
# --- Quiz Question ---
|
| 223 |
+
def show_quiz(quiz_id):
|
| 224 |
+
qobj = _load_quiz_obj(quiz_id)
|
| 225 |
+
q_index = int(st.session_state.current_q)
|
| 226 |
+
questions = qobj.get("questions", [])
|
| 227 |
+
question_data = questions[q_index]
|
| 228 |
+
|
| 229 |
+
st.header(qobj.get("title", "Quiz"))
|
| 230 |
+
st.subheader(question_data.get("question", ""))
|
| 231 |
+
|
| 232 |
+
options = question_data.get("options", [])
|
| 233 |
+
correct_answer = question_data.get("answer")
|
| 234 |
+
key = f"q_{q_index}"
|
| 235 |
+
prev_answer = st.session_state.answers.get(q_index)
|
| 236 |
+
|
| 237 |
+
if isinstance(correct_answer, list):
|
| 238 |
+
# multiselect; convert any letter defaults to texts
|
| 239 |
+
default_texts = []
|
| 240 |
+
if isinstance(prev_answer, list):
|
| 241 |
+
for a in prev_answer:
|
| 242 |
+
if isinstance(a, str):
|
| 243 |
+
if a in options:
|
| 244 |
+
default_texts.append(a)
|
| 245 |
+
elif len(a) == 1 and a.isalpha():
|
| 246 |
+
i = _letter_to_index(a)
|
| 247 |
+
if 0 <= i < len(options):
|
| 248 |
+
default_texts.append(options[i])
|
| 249 |
+
answer = st.multiselect("Select all that apply:", options, default=default_texts, key=key)
|
| 250 |
+
else:
|
| 251 |
+
# single answer; compute default index from letter or text
|
| 252 |
+
if isinstance(prev_answer, str):
|
| 253 |
+
if prev_answer in options:
|
| 254 |
+
default_idx = options.index(prev_answer)
|
| 255 |
+
elif len(prev_answer) == 1 and prev_answer.isalpha():
|
| 256 |
+
i = _letter_to_index(prev_answer)
|
| 257 |
+
default_idx = i if 0 <= i < len(options) else 0
|
| 258 |
+
else:
|
| 259 |
+
default_idx = 0
|
| 260 |
+
else:
|
| 261 |
+
default_idx = 0
|
| 262 |
+
answer = st.radio("Select your answer:", options, index=default_idx, key=key)
|
| 263 |
+
|
| 264 |
+
st.session_state.answers[q_index] = answer # auto-save
|
| 265 |
+
|
| 266 |
+
if st.button("Next Question ➡"):
|
| 267 |
+
st.session_state.current_q += 1
|
| 268 |
+
st.rerun()
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# --- Quiz Results ---
|
| 273 |
+
def show_results(quiz_id):
|
| 274 |
+
qobj = _load_quiz_obj(quiz_id)
|
| 275 |
+
questions = qobj.get("questions", [])
|
| 276 |
+
|
| 277 |
+
total_points = 0
|
| 278 |
+
earned_points = 0
|
| 279 |
+
details = {"answers": {}}
|
| 280 |
+
|
| 281 |
+
for i, q in enumerate(questions):
|
| 282 |
+
options = q.get("options", []) or []
|
| 283 |
+
pts = int(q.get("points", 1))
|
| 284 |
+
total_points += pts
|
| 285 |
+
|
| 286 |
+
correct = q.get("answer")
|
| 287 |
+
correct_idx = _correct_to_indices(correct, options)
|
| 288 |
+
|
| 289 |
+
user_answer = st.session_state.answers.get(i)
|
| 290 |
+
user_idx = _normalize_user_to_indices(user_answer, options)
|
| 291 |
+
|
| 292 |
+
is_correct = (sorted(user_idx) == sorted(correct_idx))
|
| 293 |
+
if is_correct:
|
| 294 |
+
earned_points += pts
|
| 295 |
+
|
| 296 |
+
# friendly display
|
| 297 |
+
correct_disp = ", ".join(options[j] for j in correct_idx if 0 <= j < len(options)) or str(correct)
|
| 298 |
+
user_disp = ", ".join(options[j] for j in user_idx if 0 <= j < len(options)) or (
|
| 299 |
+
", ".join(user_answer) if isinstance(user_answer, list) else str(user_answer)
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
if is_correct:
|
| 303 |
+
st.markdown(f"✅ **Q{i+1}: {q.get('question','')}** \nYour answer: {user_disp}")
|
| 304 |
+
else:
|
| 305 |
+
st.markdown(f"❌ **Q{i+1}: {q.get('question','')}** \nYour answer: {user_disp} \nCorrect answer: {correct_disp}")
|
| 306 |
+
|
| 307 |
+
details["answers"][str(i+1)] = {
|
| 308 |
+
"question": q.get("question", ""),
|
| 309 |
+
"selected": user_answer,
|
| 310 |
+
"correct": correct,
|
| 311 |
+
"points": pts,
|
| 312 |
+
"earned": pts if is_correct else 0
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
percent = int(round(100 * earned_points / max(1, total_points)))
|
| 316 |
+
st.success(f"{qobj.get('title','Quiz')} - Completed! 🎉")
|
| 317 |
+
st.markdown(f"### 🏆 Score: {percent}% ({earned_points}/{total_points} points)")
|
| 318 |
+
|
| 319 |
+
# Save submission to DB for assigned quizzes
|
| 320 |
+
if isinstance(quiz_id, int):
|
| 321 |
+
assignment_id = st.session_state.get("current_assignment")
|
| 322 |
+
if assignment_id:
|
| 323 |
+
_submit_quiz_result(
|
| 324 |
+
student_id=st.session_state.user["user_id"],
|
| 325 |
+
assignment_id=assignment_id,
|
| 326 |
+
quiz_id=quiz_id,
|
| 327 |
+
score=int(earned_points),
|
| 328 |
+
total=int(total_points),
|
| 329 |
+
details=details
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
if st.button("🔁 Retake Quiz"):
|
| 333 |
+
st.session_state.current_q = 0
|
| 334 |
+
st.session_state.answers = {}
|
| 335 |
+
st.rerun()
|
| 336 |
+
|
| 337 |
+
if st.button("⬅ Back to Quizzes"):
|
| 338 |
+
st.session_state.selected_quiz = None
|
| 339 |
+
st.rerun()
|
| 340 |
+
|
| 341 |
+
# tutor handoff (kept as-is)
|
| 342 |
+
wrong_answers = []
|
| 343 |
+
for i, q in enumerate(questions):
|
| 344 |
+
user_answer = st.session_state.answers.get(i)
|
| 345 |
+
correct = q.get("answer")
|
| 346 |
+
if (isinstance(correct, list) and set(user_answer or []) != set(correct)) or (not isinstance(correct, list) and user_answer != correct):
|
| 347 |
+
wrong_answers.append((q.get("question",""), user_answer, correct, q.get("explanation","")))
|
| 348 |
+
if wrong_answers and st.button("💬 Talk to AI Financial Tutor"):
|
| 349 |
+
st.session_state.selected_quiz = None
|
| 350 |
+
st.session_state.current_page = "Chatbot"
|
| 351 |
+
st.session_state.current_q = 0
|
| 352 |
+
st.session_state.answers = {}
|
| 353 |
+
if "messages" not in st.session_state:
|
| 354 |
+
st.session_state.messages = []
|
| 355 |
+
wrong_q_text = "\n".join(
|
| 356 |
+
[f"Q: {q}\nYour answer: {ua}\nCorrect answer: {ca}\nExplanation: {ex}"
|
| 357 |
+
for q, ua, ca, ex in wrong_answers])
|
| 358 |
+
tutor_prompt = f"I just completed a financial quiz and got some questions wrong. Here are the details:\n{wrong_q_text}\nCan you help me understand these concepts better?"
|
| 359 |
+
st.session_state.messages.append({
|
| 360 |
+
"id": str(datetime.datetime.now().timestamp()),
|
| 361 |
+
"text": tutor_prompt,
|
| 362 |
+
"sender": "user",
|
| 363 |
+
"timestamp": datetime.datetime.now()
|
| 364 |
+
})
|
| 365 |
+
st.session_state.is_typing = True
|
| 366 |
+
st.rerun()
|
| 367 |
+
|
| 368 |
+
# --- Quiz List ---
|
| 369 |
+
def show_quiz_list():
|
| 370 |
+
st.title("📊 Financial Knowledge Quizzes")
|
| 371 |
+
st.caption("Test your financial literacy across different modules")
|
| 372 |
+
|
| 373 |
+
cols = st.columns(3)
|
| 374 |
+
for i, (quiz_id, quiz) in enumerate(quizzes_data.items()):
|
| 375 |
+
col = cols[i % 3]
|
| 376 |
+
with col:
|
| 377 |
+
color, label = get_level_style(quiz["level"])
|
| 378 |
+
st.markdown(f"""
|
| 379 |
+
<div style="border:1px solid #e1e5e9; border-radius:12px; padding:20px; margin-bottom:20px; background:white; box-shadow:0 2px 6px rgba(0,0,0,0.08);">
|
| 380 |
+
<span style="background-color:{color}; color:white; font-size:12px; padding:4px 8px; border-radius:6px;">{label}</span>
|
| 381 |
+
<span style="float:right; color:#666; font-size:13px;">⏱ {quiz['duration']}</span>
|
| 382 |
+
<h4 style="margin-top:10px; margin-bottom:6px; color:#222;">{quiz['title']}</h4>
|
| 383 |
+
<p style="font-size:14px; color:#555; line-height:1.4; margin-bottom:10px;">{quiz['description']}</p>
|
| 384 |
+
<p style="font-size:13px; color:#666;">📝 {len(quiz['questions'])} questions</p>
|
| 385 |
+
</div>
|
| 386 |
+
""", unsafe_allow_html=True)
|
| 387 |
+
|
| 388 |
+
if st.button("Start Quiz ➡", key=f"quiz_{quiz_id}"):
|
| 389 |
+
st.session_state.selected_quiz = quiz_id
|
| 390 |
+
st.session_state.current_q = 0
|
| 391 |
+
st.session_state.answers = {}
|
| 392 |
+
st.rerun()
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# --- Main Router for Quiz Page ---
|
| 396 |
+
def show_page():
|
| 397 |
+
if "selected_quiz" not in st.session_state:
|
| 398 |
+
st.session_state.selected_quiz = None
|
| 399 |
+
if "current_q" not in st.session_state:
|
| 400 |
+
st.session_state.current_q = 0
|
| 401 |
+
if "answers" not in st.session_state:
|
| 402 |
+
st.session_state.answers = {}
|
| 403 |
+
|
| 404 |
+
if st.session_state.selected_quiz is None:
|
| 405 |
+
show_quiz_list()
|
| 406 |
+
else:
|
| 407 |
+
quiz_id = st.session_state.selected_quiz
|
| 408 |
+
qobj = _load_quiz_obj(quiz_id)
|
| 409 |
+
total_q = len(qobj.get("questions", []))
|
| 410 |
+
if st.session_state.current_q < total_q:
|
| 411 |
+
show_quiz(quiz_id)
|
| 412 |
+
else:
|
| 413 |
+
show_results(quiz_id)
|
phase/Student_view/teacherlink.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase/Student_view/teacherlink.py
|
| 2 |
+
import os
|
| 3 |
+
import streamlit as st
|
| 4 |
+
from utils import db as dbapi
|
| 5 |
+
import utils.api as api # <-- backend Space client
|
| 6 |
+
|
| 7 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1" # DB only when DISABLE_DB=0
|
| 8 |
+
|
| 9 |
+
def load_css(file_name: str):
|
| 10 |
+
try:
|
| 11 |
+
with open(file_name, "r", encoding="utf-8") as f:
|
| 12 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 13 |
+
except FileNotFoundError:
|
| 14 |
+
pass
|
| 15 |
+
|
| 16 |
+
def _progress_0_1(v):
|
| 17 |
+
try:
|
| 18 |
+
f = float(v)
|
| 19 |
+
except Exception:
|
| 20 |
+
return 0.0
|
| 21 |
+
# accept 0..1 or 0..100
|
| 22 |
+
return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
|
| 23 |
+
|
| 24 |
+
# --- Small wrappers to switch between DB and Backend ---
|
| 25 |
+
|
| 26 |
+
def _join_class_by_code(student_id: int, code: str):
|
| 27 |
+
if USE_LOCAL_DB and hasattr(dbapi, "join_class_by_code"):
|
| 28 |
+
return dbapi.join_class_by_code(student_id, code)
|
| 29 |
+
return api.join_class_by_code(student_id, code)
|
| 30 |
+
|
| 31 |
+
def _list_classes_for_student(student_id: int):
|
| 32 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_classes_for_student"):
|
| 33 |
+
return dbapi.list_classes_for_student(student_id)
|
| 34 |
+
try:
|
| 35 |
+
return api.list_classes_for_student(student_id)
|
| 36 |
+
except Exception:
|
| 37 |
+
return []
|
| 38 |
+
|
| 39 |
+
def _class_content_counts(class_id: int):
|
| 40 |
+
if USE_LOCAL_DB and hasattr(dbapi, "class_content_counts"):
|
| 41 |
+
return dbapi.class_content_counts(class_id)
|
| 42 |
+
try:
|
| 43 |
+
return api.class_content_counts(class_id)
|
| 44 |
+
except Exception:
|
| 45 |
+
return {"lessons": 0, "quizzes": 0}
|
| 46 |
+
|
| 47 |
+
def _student_class_progress(student_id: int, class_id: int):
|
| 48 |
+
if USE_LOCAL_DB and hasattr(dbapi, "student_class_progress"):
|
| 49 |
+
return dbapi.student_class_progress(student_id, class_id)
|
| 50 |
+
try:
|
| 51 |
+
return api.student_class_progress(student_id, class_id)
|
| 52 |
+
except Exception:
|
| 53 |
+
return {
|
| 54 |
+
"overall_progress": 0,
|
| 55 |
+
"lessons_completed": 0,
|
| 56 |
+
"total_assigned_lessons": 0,
|
| 57 |
+
"avg_score": 0,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
def _leave_class(student_id: int, class_id: int):
|
| 61 |
+
if USE_LOCAL_DB and hasattr(dbapi, "leave_class"):
|
| 62 |
+
return dbapi.leave_class(student_id, class_id)
|
| 63 |
+
return api.leave_class(student_id, class_id)
|
| 64 |
+
|
| 65 |
+
def _student_assignments_for_class(student_id: int, class_id: int):
|
| 66 |
+
if USE_LOCAL_DB and hasattr(dbapi, "student_assignments_for_class"):
|
| 67 |
+
return dbapi.student_assignments_for_class(student_id, class_id)
|
| 68 |
+
try:
|
| 69 |
+
return api.student_assignments_for_class(student_id, class_id)
|
| 70 |
+
except Exception:
|
| 71 |
+
return []
|
| 72 |
+
|
| 73 |
+
# --- UI ---
|
| 74 |
+
|
| 75 |
+
def show_code():
|
| 76 |
+
load_css(os.path.join("assets", "styles.css"))
|
| 77 |
+
|
| 78 |
+
if "user" not in st.session_state or not st.session_state.user:
|
| 79 |
+
st.error("Please log in as a student.")
|
| 80 |
+
return
|
| 81 |
+
if st.session_state.user["role"] != "Student":
|
| 82 |
+
st.error("This page is for students.")
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
student_id = st.session_state.user["user_id"]
|
| 86 |
+
st.markdown("## 👥 Join a Class")
|
| 87 |
+
st.caption("Enter class code from your teacher")
|
| 88 |
+
|
| 89 |
+
raw = st.text_input(
|
| 90 |
+
label="Class Code",
|
| 91 |
+
placeholder="e.g. FIN5A2024",
|
| 92 |
+
key="class_code_input",
|
| 93 |
+
label_visibility="collapsed"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# custom button style
|
| 97 |
+
st.markdown(
|
| 98 |
+
"""
|
| 99 |
+
<style>
|
| 100 |
+
.stButton>button#join_class_btn {
|
| 101 |
+
background-color: #28a745; /* Bootstrap green */
|
| 102 |
+
color: white;
|
| 103 |
+
border-radius: 5px;
|
| 104 |
+
padding: 10px 16px;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
}
|
| 107 |
+
.stButton>button#join_class_btn:hover {
|
| 108 |
+
background-color: #218838;
|
| 109 |
+
color: white;
|
| 110 |
+
}
|
| 111 |
+
</style>
|
| 112 |
+
""",
|
| 113 |
+
unsafe_allow_html=True,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
if st.button("Join Class", key="join_class_btn"):
|
| 117 |
+
code = (raw or "").strip().upper()
|
| 118 |
+
if not code:
|
| 119 |
+
st.error("Enter a class code.")
|
| 120 |
+
else:
|
| 121 |
+
try:
|
| 122 |
+
_join_class_by_code(student_id, code)
|
| 123 |
+
st.success("🎉 Joined the class!")
|
| 124 |
+
st.rerun()
|
| 125 |
+
except ValueError as e:
|
| 126 |
+
st.error(str(e))
|
| 127 |
+
except Exception as e:
|
| 128 |
+
st.error(f"Could not join class: {e}")
|
| 129 |
+
|
| 130 |
+
st.markdown("---")
|
| 131 |
+
st.markdown("## Your Classes")
|
| 132 |
+
|
| 133 |
+
classes = _list_classes_for_student(student_id)
|
| 134 |
+
if not classes:
|
| 135 |
+
st.info("You haven’t joined any classes yet. Ask your teacher for a class code.")
|
| 136 |
+
return
|
| 137 |
+
|
| 138 |
+
# one card per class
|
| 139 |
+
for c in classes:
|
| 140 |
+
class_id = c["class_id"]
|
| 141 |
+
counts = _class_content_counts(class_id) # lessons/quizzes count
|
| 142 |
+
prog = _student_class_progress(student_id, class_id)
|
| 143 |
+
|
| 144 |
+
st.markdown(f"### {c.get('name', 'Untitled Class')}")
|
| 145 |
+
st.caption(
|
| 146 |
+
f"Teacher: {c.get('teacher_name','—')} • "
|
| 147 |
+
f"Code: {c.get('code','—')} • "
|
| 148 |
+
f"Joined: {str(c.get('joined_at',''))[:10] if c.get('joined_at') else '—'}"
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
st.progress(_progress_0_1(prog.get("overall_progress", 0)))
|
| 152 |
+
avg_pct = int(round(100 * _progress_0_1(prog.get("avg_score", 0))))
|
| 153 |
+
st.caption(
|
| 154 |
+
f"{prog.get('lessons_completed', 0)}/{prog.get('total_assigned_lessons', 0)} lessons completed • "
|
| 155 |
+
f"Avg quiz: {avg_pct}%"
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
# top metrics
|
| 159 |
+
m1, m2, m3, m4 = st.columns(4)
|
| 160 |
+
m1.metric("Lessons", counts.get("lessons", 0))
|
| 161 |
+
m2.metric("Quizzes", counts.get("quizzes", 0))
|
| 162 |
+
m3.metric("Overall", f"{int(round(100 * _progress_0_1(prog.get('overall_progress', 0))))}%")
|
| 163 |
+
m4.metric("Avg Quiz", f"{avg_pct}%")
|
| 164 |
+
|
| 165 |
+
# Leave class
|
| 166 |
+
leave_col, _ = st.columns([1, 3])
|
| 167 |
+
with leave_col:
|
| 168 |
+
if st.button("🚪 Leave Class", key=f"leave_{class_id}"):
|
| 169 |
+
try:
|
| 170 |
+
_leave_class(student_id, class_id)
|
| 171 |
+
st.toast("Left class.", icon="👋")
|
| 172 |
+
st.rerun()
|
| 173 |
+
except Exception as e:
|
| 174 |
+
st.error(f"Could not leave class: {e}")
|
| 175 |
+
|
| 176 |
+
# Assignments for THIS class with THIS student's progress
|
| 177 |
+
st.markdown("#### Teacher Lessons & Quizzes")
|
| 178 |
+
rows = _student_assignments_for_class(student_id, class_id)
|
| 179 |
+
if not rows:
|
| 180 |
+
st.info("No assignments yet.")
|
| 181 |
+
else:
|
| 182 |
+
lessons_tab, quizzes_tab = st.tabs(["📘 Lessons", "🏆 Quizzes"])
|
| 183 |
+
|
| 184 |
+
with lessons_tab:
|
| 185 |
+
for r in rows:
|
| 186 |
+
if r.get("lesson_id") is None:
|
| 187 |
+
continue
|
| 188 |
+
|
| 189 |
+
status = r.get("status") or "not_started"
|
| 190 |
+
pos = r.get("current_pos") or 0
|
| 191 |
+
# if backend returns explicit progress % or 0..1, keep it sane:
|
| 192 |
+
pct = r.get("progress")
|
| 193 |
+
if pct is None:
|
| 194 |
+
# fallback: estimate from position
|
| 195 |
+
pct = 1.0 if status == "completed" else min(0.95, float(pos or 0) * 0.1)
|
| 196 |
+
|
| 197 |
+
st.subheader(r.get("title", "Untitled"))
|
| 198 |
+
due = r.get("due_at")
|
| 199 |
+
due_txt = f"Due: {str(due)[:10]}" if due else "—"
|
| 200 |
+
st.caption(f"{r.get('subject','General')} • {r.get('level','Beginner')} • {due_txt}")
|
| 201 |
+
st.progress(_progress_0_1(pct))
|
| 202 |
+
|
| 203 |
+
c1, c2 = st.columns(2)
|
| 204 |
+
with c1:
|
| 205 |
+
# pass lesson & assignment to the Lessons page
|
| 206 |
+
if st.button("▶️ Start Lesson", key=f"start_lesson_{r.get('assignment_id')}"):
|
| 207 |
+
st.session_state.selected_lesson = r.get("lesson_id")
|
| 208 |
+
st.session_state.selected_assignment = r.get("assignment_id")
|
| 209 |
+
st.session_state.current_page = "Lessons"
|
| 210 |
+
st.rerun()
|
| 211 |
+
with c2:
|
| 212 |
+
st.write(f"Status: **{status}**")
|
| 213 |
+
|
| 214 |
+
with quizzes_tab:
|
| 215 |
+
any_quiz = False
|
| 216 |
+
for r in rows:
|
| 217 |
+
quiz_id = r.get("quiz_id")
|
| 218 |
+
if not quiz_id:
|
| 219 |
+
continue
|
| 220 |
+
any_quiz = True
|
| 221 |
+
|
| 222 |
+
st.subheader(r.get("title", "Untitled"))
|
| 223 |
+
score, total = r.get("score"), r.get("total")
|
| 224 |
+
if score is not None and total:
|
| 225 |
+
try:
|
| 226 |
+
pct = int(round(100 * float(score) / float(total)))
|
| 227 |
+
st.caption(f"Last score: {pct}%")
|
| 228 |
+
except Exception:
|
| 229 |
+
st.caption("Last score: —")
|
| 230 |
+
else:
|
| 231 |
+
st.caption("No submission yet")
|
| 232 |
+
|
| 233 |
+
# pass quiz & assignment to the Quiz page
|
| 234 |
+
if st.button("📝 Start Quiz", key=f"start_quiz_{class_id}_{quiz_id}"):
|
| 235 |
+
st.session_state.selected_quiz = quiz_id
|
| 236 |
+
st.session_state.current_assignment = r.get("assignment_id")
|
| 237 |
+
st.session_state.current_page = "Quiz"
|
| 238 |
+
st.rerun()
|
| 239 |
+
|
| 240 |
+
if not any_quiz:
|
| 241 |
+
st.info("No quizzes yet for this class.")
|
| 242 |
+
|
| 243 |
+
st.markdown("---")
|
phase/Teacher_view/classmanage.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase/Teacher_view/classmanage.py
|
| 2 |
+
import os
|
| 3 |
+
import streamlit as st
|
| 4 |
+
|
| 5 |
+
from utils import db as dbapi
|
| 6 |
+
import utils.api as api # backend Space client
|
| 7 |
+
|
| 8 |
+
# When DISABLE_DB=1 (default), skip direct MySQL and use backend APIs
|
| 9 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _metric_card(label: str, value: str, caption: str = ""):
|
| 13 |
+
st.markdown(
|
| 14 |
+
f"""
|
| 15 |
+
<div class="metric-card">
|
| 16 |
+
<div class="metric-value">{value}</div>
|
| 17 |
+
<div class="metric-label">{label}</div>
|
| 18 |
+
<div class="metric-caption">{caption}</div>
|
| 19 |
+
</div>
|
| 20 |
+
""",
|
| 21 |
+
unsafe_allow_html=True,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _prefer_db(db_name: str, api_func, default, *args, **kwargs):
|
| 26 |
+
"""
|
| 27 |
+
Try local DB function if enabled & present; else call backend API; else return default.
|
| 28 |
+
"""
|
| 29 |
+
if USE_LOCAL_DB and hasattr(dbapi, db_name):
|
| 30 |
+
try:
|
| 31 |
+
return getattr(dbapi, db_name)(*args, **kwargs)
|
| 32 |
+
except Exception as e:
|
| 33 |
+
st.warning(f"DB call {db_name} failed; falling back to backend. ({e})")
|
| 34 |
+
try:
|
| 35 |
+
return api_func(*args, **kwargs)
|
| 36 |
+
except Exception as e:
|
| 37 |
+
st.error(f"Backend call failed: {e}")
|
| 38 |
+
return default
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def show_page():
|
| 42 |
+
user = st.session_state.user
|
| 43 |
+
teacher_id = user["user_id"]
|
| 44 |
+
|
| 45 |
+
st.title("📚 Classroom Management")
|
| 46 |
+
st.caption("Manage all your classrooms and students")
|
| 47 |
+
|
| 48 |
+
# -------- Create Classroom --------
|
| 49 |
+
with st.expander("➕ Create Classroom", expanded=False):
|
| 50 |
+
new_name = st.text_input("Classroom Name", key="new_classroom_name")
|
| 51 |
+
if st.button("Create Classroom"):
|
| 52 |
+
name = new_name.strip()
|
| 53 |
+
if not name:
|
| 54 |
+
st.error("Enter a real name, not whitespace.")
|
| 55 |
+
else:
|
| 56 |
+
out = _prefer_db(
|
| 57 |
+
"create_class",
|
| 58 |
+
lambda tid, n: api.create_class(tid, n),
|
| 59 |
+
None,
|
| 60 |
+
teacher_id, # positional arg
|
| 61 |
+
name, # positional arg
|
| 62 |
+
)
|
| 63 |
+
if out:
|
| 64 |
+
st.session_state.selected_class_id = out.get("class_id") or out.get("id")
|
| 65 |
+
st.success(f'Classroom "{name}" created with code: {out.get("code","—")}')
|
| 66 |
+
st.rerun()
|
| 67 |
+
else:
|
| 68 |
+
st.error("Could not create classroom (no response).")
|
| 69 |
+
|
| 70 |
+
# -------- Load classes for this teacher --------
|
| 71 |
+
classes = _prefer_db(
|
| 72 |
+
"list_classes_by_teacher",
|
| 73 |
+
lambda tid: api.list_classes_by_teacher(tid),
|
| 74 |
+
[],
|
| 75 |
+
teacher_id, # positional
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if not classes:
|
| 79 |
+
st.info("No classrooms yet. Create one above, then share the code.")
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
# Picker
|
| 83 |
+
st.subheader("Your Classrooms")
|
| 84 |
+
options = {f"{c.get('name','(unnamed)')} (Code: {c.get('code','')})": c for c in classes}
|
| 85 |
+
selected_label = st.selectbox("Select a classroom", list(options.keys()))
|
| 86 |
+
selected = options[selected_label]
|
| 87 |
+
class_id = selected.get("class_id") or selected.get("id")
|
| 88 |
+
|
| 89 |
+
st.markdown("---")
|
| 90 |
+
st.header(selected.get("name", "Classroom"))
|
| 91 |
+
|
| 92 |
+
# -------- Code stripe --------
|
| 93 |
+
st.subheader("Class Code")
|
| 94 |
+
c1, c2, c3 = st.columns([3, 1, 1])
|
| 95 |
+
with c1:
|
| 96 |
+
st.markdown(f"**`{selected.get('code', 'UNKNOWN')}`**")
|
| 97 |
+
with c2:
|
| 98 |
+
if st.button("📋 Copy Code"):
|
| 99 |
+
st.toast("Code is shown above. Copy it.")
|
| 100 |
+
with c3:
|
| 101 |
+
st.button("🗑️ Delete Class", disabled=True, help="Soft-delete coming later")
|
| 102 |
+
|
| 103 |
+
# -------- Tabs --------
|
| 104 |
+
tab_students, tab_content, tab_analytics = st.tabs(["👥 Students", "📘 Content", "📊 Analytics"])
|
| 105 |
+
|
| 106 |
+
# ============== Students tab ==============
|
| 107 |
+
with tab_students:
|
| 108 |
+
q = st.text_input("Search students by name or email", "")
|
| 109 |
+
roster = _prefer_db(
|
| 110 |
+
"list_students_in_class",
|
| 111 |
+
lambda cid: api.list_students_in_class(cid),
|
| 112 |
+
[],
|
| 113 |
+
class_id, # positional
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# simple filter
|
| 117 |
+
if q.strip():
|
| 118 |
+
ql = q.lower()
|
| 119 |
+
roster = [r for r in roster if ql in (r.get("name","").lower()) or ql in (r.get("email","").lower())]
|
| 120 |
+
|
| 121 |
+
st.caption(f"{len(roster)} Students Found")
|
| 122 |
+
|
| 123 |
+
if not roster:
|
| 124 |
+
st.info("No students in this class yet.")
|
| 125 |
+
else:
|
| 126 |
+
for s in roster:
|
| 127 |
+
st.subheader(f"👤 {s.get('name','(unknown)')}")
|
| 128 |
+
st.caption(s.get("email","—"))
|
| 129 |
+
joined = s.get("joined_at") or s.get("created_at")
|
| 130 |
+
st.caption(f"📅 Joined: {str(joined)[:10] if joined else '—'}")
|
| 131 |
+
st.progress(0.0) # placeholder bar
|
| 132 |
+
cols = st.columns(3)
|
| 133 |
+
level_slug = (s.get("level_slug") or s.get("level") or "beginner")
|
| 134 |
+
try:
|
| 135 |
+
level_label = level_slug.capitalize() if isinstance(level_slug, str) else str(level_slug)
|
| 136 |
+
except Exception:
|
| 137 |
+
level_label = "—"
|
| 138 |
+
cols[0].metric("⭐ Level", level_label)
|
| 139 |
+
cols[1].metric("📊 Avg Score", "—")
|
| 140 |
+
cols[2].metric("🔥 Streak", "—")
|
| 141 |
+
st.markdown("---")
|
| 142 |
+
|
| 143 |
+
# ============== Content tab ==============
|
| 144 |
+
with tab_content:
|
| 145 |
+
counts = _prefer_db(
|
| 146 |
+
"class_content_counts",
|
| 147 |
+
lambda cid: api.class_content_counts(cid),
|
| 148 |
+
{"lessons": 0, "quizzes": 0},
|
| 149 |
+
class_id, # positional
|
| 150 |
+
)
|
| 151 |
+
left, right = st.columns(2)
|
| 152 |
+
with left:
|
| 153 |
+
_metric_card("📖 Custom Lessons", str(counts.get("lessons", 0)), "Lessons created for this classroom")
|
| 154 |
+
with right:
|
| 155 |
+
_metric_card("🏆 Custom Quizzes", str(counts.get("quizzes", 0)), "Quizzes created for this classroom")
|
| 156 |
+
|
| 157 |
+
assigs = _prefer_db(
|
| 158 |
+
"list_class_assignments",
|
| 159 |
+
lambda cid: api.list_class_assignments(cid),
|
| 160 |
+
[],
|
| 161 |
+
class_id, # positional
|
| 162 |
+
)
|
| 163 |
+
if assigs:
|
| 164 |
+
st.markdown("#### Assigned items")
|
| 165 |
+
for a in assigs:
|
| 166 |
+
has_quiz = " + Quiz" if a.get("quiz_id") else ""
|
| 167 |
+
st.markdown(f"- **{a.get('title','Untitled')}** · {a.get('subject','—')} · {a.get('level','—')}{has_quiz}")
|
| 168 |
+
|
| 169 |
+
# ============== Analytics tab ==============
|
| 170 |
+
with tab_analytics:
|
| 171 |
+
stats = _prefer_db(
|
| 172 |
+
"class_analytics",
|
| 173 |
+
lambda cid: api.class_analytics(cid),
|
| 174 |
+
{"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0},
|
| 175 |
+
class_id, # positional
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
class_avg_pct = round(float(stats.get("class_avg", 0)) * 100) if stats.get("class_avg") is not None else 0
|
| 179 |
+
total_xp = stats.get("total_xp", 0)
|
| 180 |
+
lessons_completed = stats.get("lessons_completed", 0)
|
| 181 |
+
|
| 182 |
+
g1, g2, g3 = st.columns(3)
|
| 183 |
+
with g1:
|
| 184 |
+
_metric_card("📊 Class Average", f"{class_avg_pct}%", "Average quiz performance")
|
| 185 |
+
with g2:
|
| 186 |
+
_metric_card("🪙 Total XP", f"{total_xp}", "Combined XP earned")
|
| 187 |
+
with g3:
|
| 188 |
+
_metric_card("📘 Lessons Completed", f"{lessons_completed}", "Total lessons completed")
|
phase/Teacher_view/contentmanage.py
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase/Teacher_view/contentmanage.py
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import streamlit as st
|
| 6 |
+
from utils import db as dbapi
|
| 7 |
+
import utils.api as api # backend Space client
|
| 8 |
+
|
| 9 |
+
# Switch automatically: if DISABLE_DB=1 (default), use backend API; else use local DB
|
| 10 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 11 |
+
|
| 12 |
+
# ---------- small UI helpers ----------
|
| 13 |
+
def _pill(text):
|
| 14 |
+
return f"<span style='background:#eef6ff;border:1px solid #cfe3ff;border-radius:999px;padding:2px 8px;font-size:12px;margin-right:6px'>{text}</span>"
|
| 15 |
+
|
| 16 |
+
def _progress(val: float):
|
| 17 |
+
pct = max(0, min(100, int(round(val * 100))))
|
| 18 |
+
return f"""
|
| 19 |
+
<div style="height:8px;background:#eef2ff;border-radius:999px;overflow:hidden">
|
| 20 |
+
<div style="width:{pct}%;height:100%;background:#3b82f6"></div>
|
| 21 |
+
</div>
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
def _fmt_date(v):
|
| 25 |
+
if isinstance(v, datetime):
|
| 26 |
+
return v.strftime("%Y-%m-%d")
|
| 27 |
+
try:
|
| 28 |
+
s = str(v)
|
| 29 |
+
return s[:10]
|
| 30 |
+
except Exception:
|
| 31 |
+
return ""
|
| 32 |
+
|
| 33 |
+
# ---------- Quiz generator via backend LLM (llama 3.1 8B) ----------
|
| 34 |
+
def _generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
|
| 35 |
+
"""
|
| 36 |
+
Calls your backend, which uses GEN_MODEL (llama-3.1-8b-instruct).
|
| 37 |
+
Returns a normalized list like:
|
| 38 |
+
[{"question":"...","options":["A","B","C","D"],"answer_key":"B","points":1}, ...]
|
| 39 |
+
"""
|
| 40 |
+
def _normalize(items):
|
| 41 |
+
out = []
|
| 42 |
+
for it in (items or [])[:n_questions]:
|
| 43 |
+
q = str(it.get("question", "")).strip()
|
| 44 |
+
opts = it.get("options", [])
|
| 45 |
+
if not q or not isinstance(opts, list):
|
| 46 |
+
continue
|
| 47 |
+
while len(opts) < 4:
|
| 48 |
+
opts.append("Option")
|
| 49 |
+
opts = opts[:4]
|
| 50 |
+
key = str(it.get("answer_key", "A")).strip().upper()[:1]
|
| 51 |
+
if key not in ("A","B","C","D"):
|
| 52 |
+
key = "A"
|
| 53 |
+
out.append({"question": q, "options": opts, "answer_key": key, "points": 1})
|
| 54 |
+
return out
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
resp = api.generate_quiz_from_text(content, n_questions=n_questions, subject=subject, level=level)
|
| 58 |
+
items = resp.get("items", resp) # allow backend to return either shape
|
| 59 |
+
return _normalize(items)
|
| 60 |
+
except Exception as e:
|
| 61 |
+
with st.expander("Quiz generation error details"):
|
| 62 |
+
st.code(str(e))
|
| 63 |
+
st.warning("Quiz generation failed via backend. Check the /quiz/generate endpoint and GEN_MODEL.")
|
| 64 |
+
return []
|
| 65 |
+
|
| 66 |
+
# ---------- Thin wrappers that choose DB or Backend ----------
|
| 67 |
+
def _list_classes_by_teacher(teacher_id: int):
|
| 68 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"):
|
| 69 |
+
return dbapi.list_classes_by_teacher(teacher_id)
|
| 70 |
+
try:
|
| 71 |
+
return api.list_classes_by_teacher(teacher_id)
|
| 72 |
+
except Exception:
|
| 73 |
+
return []
|
| 74 |
+
|
| 75 |
+
def _list_all_students_for_teacher(teacher_id: int):
|
| 76 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_all_students_for_teacher"):
|
| 77 |
+
return dbapi.list_all_students_for_teacher(teacher_id)
|
| 78 |
+
try:
|
| 79 |
+
return api.list_all_students_for_teacher(teacher_id)
|
| 80 |
+
except Exception:
|
| 81 |
+
return []
|
| 82 |
+
|
| 83 |
+
def _list_lessons_by_teacher(teacher_id: int):
|
| 84 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_lessons_by_teacher"):
|
| 85 |
+
return dbapi.list_lessons_by_teacher(teacher_id)
|
| 86 |
+
try:
|
| 87 |
+
return api.list_lessons_by_teacher(teacher_id)
|
| 88 |
+
except Exception:
|
| 89 |
+
return []
|
| 90 |
+
|
| 91 |
+
def _list_quizzes_by_teacher(teacher_id: int):
|
| 92 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_quizzes_by_teacher"):
|
| 93 |
+
return dbapi.list_quizzes_by_teacher(teacher_id)
|
| 94 |
+
try:
|
| 95 |
+
return api.list_quizzes_by_teacher(teacher_id)
|
| 96 |
+
except Exception:
|
| 97 |
+
return []
|
| 98 |
+
|
| 99 |
+
def _create_lesson(teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
|
| 100 |
+
if USE_LOCAL_DB and hasattr(dbapi, "create_lesson"):
|
| 101 |
+
return dbapi.create_lesson(teacher_id, title, description, subject, level, sections)
|
| 102 |
+
return api.create_lesson(teacher_id, title, description, subject, level, sections)
|
| 103 |
+
|
| 104 |
+
def _update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
|
| 105 |
+
if USE_LOCAL_DB and hasattr(dbapi, "update_lesson"):
|
| 106 |
+
return dbapi.update_lesson(lesson_id, teacher_id, title, description, subject, level, sections)
|
| 107 |
+
return api.update_lesson(lesson_id, teacher_id, title, description, subject, level, sections)
|
| 108 |
+
|
| 109 |
+
def _delete_lesson(lesson_id: int, teacher_id: int):
|
| 110 |
+
if USE_LOCAL_DB and hasattr(dbapi, "delete_lesson"):
|
| 111 |
+
return dbapi.delete_lesson(lesson_id, teacher_id)
|
| 112 |
+
return api.delete_lesson(lesson_id, teacher_id)
|
| 113 |
+
|
| 114 |
+
def _get_lesson(lesson_id: int):
|
| 115 |
+
if USE_LOCAL_DB and hasattr(dbapi, "get_lesson"):
|
| 116 |
+
return dbapi.get_lesson(lesson_id)
|
| 117 |
+
return api.get_lesson(lesson_id)
|
| 118 |
+
|
| 119 |
+
def _create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
|
| 120 |
+
if USE_LOCAL_DB and hasattr(dbapi, "create_quiz"):
|
| 121 |
+
return dbapi.create_quiz(lesson_id, title, items, settings)
|
| 122 |
+
return api.create_quiz(lesson_id, title, items, settings)
|
| 123 |
+
|
| 124 |
+
def _update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict):
|
| 125 |
+
if USE_LOCAL_DB and hasattr(dbapi, "update_quiz"):
|
| 126 |
+
return dbapi.update_quiz(quiz_id, teacher_id, title, items, settings)
|
| 127 |
+
return api.update_quiz(quiz_id, teacher_id, title, items, settings)
|
| 128 |
+
|
| 129 |
+
def _delete_quiz(quiz_id: int, teacher_id: int):
|
| 130 |
+
if USE_LOCAL_DB and hasattr(dbapi, "delete_quiz"):
|
| 131 |
+
return dbapi.delete_quiz(quiz_id, teacher_id)
|
| 132 |
+
return api.delete_quiz(quiz_id, teacher_id)
|
| 133 |
+
|
| 134 |
+
def _list_assigned_students_for_lesson(lesson_id: int):
|
| 135 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_assigned_students_for_lesson"):
|
| 136 |
+
return dbapi.list_assigned_students_for_lesson(lesson_id)
|
| 137 |
+
return api.list_assigned_students_for_lesson(lesson_id)
|
| 138 |
+
|
| 139 |
+
def _list_assigned_students_for_quiz(quiz_id: int):
|
| 140 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_assigned_students_for_quiz"):
|
| 141 |
+
return dbapi.list_assigned_students_for_quiz(quiz_id)
|
| 142 |
+
return api.list_assigned_students_for_quiz(quiz_id)
|
| 143 |
+
|
| 144 |
+
def _assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int):
|
| 145 |
+
if USE_LOCAL_DB and hasattr(dbapi, "assign_to_class"):
|
| 146 |
+
return dbapi.assign_to_class(lesson_id, quiz_id, class_id, teacher_id)
|
| 147 |
+
return api.assign_to_class(lesson_id, quiz_id, class_id, teacher_id)
|
| 148 |
+
|
| 149 |
+
# ---------- Create panels ----------
|
| 150 |
+
def _create_lesson_panel(teacher_id: int):
|
| 151 |
+
st.markdown("### ✍️ Create New Lesson")
|
| 152 |
+
|
| 153 |
+
classes = _list_classes_by_teacher(teacher_id)
|
| 154 |
+
class_opts = {f"{c['name']} (code {c['code']})": c["class_id"] for c in classes} if classes else {}
|
| 155 |
+
|
| 156 |
+
if "cl_topic_count" not in st.session_state:
|
| 157 |
+
st.session_state.cl_topic_count = 2 # start with two topics
|
| 158 |
+
|
| 159 |
+
cols_btn = st.columns([1,1,6])
|
| 160 |
+
with cols_btn[0]:
|
| 161 |
+
if st.button("➕ Add topic", type="secondary"):
|
| 162 |
+
st.session_state.cl_topic_count = min(20, st.session_state.cl_topic_count + 1)
|
| 163 |
+
st.rerun()
|
| 164 |
+
with cols_btn[1]:
|
| 165 |
+
if st.button("➖ Remove last", type="secondary", disabled=st.session_state.cl_topic_count <= 1):
|
| 166 |
+
st.session_state.cl_topic_count = max(1, st.session_state.cl_topic_count - 1)
|
| 167 |
+
st.rerun()
|
| 168 |
+
|
| 169 |
+
with st.form("create_lesson_form", clear_on_submit=False):
|
| 170 |
+
c1, c2 = st.columns([2,1])
|
| 171 |
+
title = c1.text_input("Title", placeholder="e.g., Jamaican Money Recognition")
|
| 172 |
+
level = c2.selectbox("Level", ["beginner","intermediate","advanced"], index=0)
|
| 173 |
+
description = st.text_area("Short description")
|
| 174 |
+
subject = st.selectbox("Subject", ["numeracy","finance"], index=0)
|
| 175 |
+
|
| 176 |
+
st.markdown("#### Topics")
|
| 177 |
+
topic_rows = []
|
| 178 |
+
for i in range(1, st.session_state.cl_topic_count + 1):
|
| 179 |
+
with st.expander(f"Topic {i}", expanded=True if i <= 2 else False):
|
| 180 |
+
t = st.text_input(f"Topic {i} title", key=f"t_title_{i}")
|
| 181 |
+
b = st.text_area(f"Topic {i} content", key=f"t_body_{i}", height=150)
|
| 182 |
+
topic_rows.append((t, b))
|
| 183 |
+
|
| 184 |
+
add_summary = st.checkbox("Append a Summary section at the end", value=True)
|
| 185 |
+
summary_text = ""
|
| 186 |
+
if add_summary:
|
| 187 |
+
summary_text = st.text_area(
|
| 188 |
+
"Summary notes",
|
| 189 |
+
key="summary_notes",
|
| 190 |
+
height=120,
|
| 191 |
+
placeholder="Key ideas, local examples, common mistakes, quick recap..."
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
st.markdown("#### Assign to class (optional)")
|
| 195 |
+
assign_classes = st.multiselect("Choose one or more classes", list(class_opts.keys()))
|
| 196 |
+
|
| 197 |
+
st.markdown("#### Auto-generate a quiz from this lesson (optional)")
|
| 198 |
+
gen_quiz = st.checkbox("Generate a quiz from content", value=False)
|
| 199 |
+
q_count = st.slider("", 3, 10, 5)
|
| 200 |
+
|
| 201 |
+
submitted = st.form_submit_button("Create lesson", type="primary")
|
| 202 |
+
|
| 203 |
+
if not submitted:
|
| 204 |
+
return
|
| 205 |
+
|
| 206 |
+
sections = []
|
| 207 |
+
for t, b in topic_rows:
|
| 208 |
+
if (t or b):
|
| 209 |
+
sections.append({"title": t or "Topic", "content": b or ""})
|
| 210 |
+
|
| 211 |
+
if add_summary:
|
| 212 |
+
sections.append({
|
| 213 |
+
"title": "Summary",
|
| 214 |
+
"content": (summary_text or "Write a short recap of the most important ideas.").strip()
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
if not title or not sections:
|
| 218 |
+
st.error("Please add a title and at least one topic.")
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
# create lesson (DB or backend)
|
| 222 |
+
try:
|
| 223 |
+
lesson_id = _create_lesson(teacher_id, title, description, subject, level, sections)
|
| 224 |
+
st.success(f"✅ Lesson created (ID {lesson_id}).")
|
| 225 |
+
except Exception as e:
|
| 226 |
+
st.error(f"Failed to create lesson: {e}")
|
| 227 |
+
return
|
| 228 |
+
|
| 229 |
+
# assign to chosen classes (lesson only for now)
|
| 230 |
+
for label in assign_classes:
|
| 231 |
+
try:
|
| 232 |
+
_assign_to_class(lesson_id, None, class_opts[label], teacher_id)
|
| 233 |
+
except Exception as e:
|
| 234 |
+
st.warning(f"Could not assign to {label}: {e}")
|
| 235 |
+
|
| 236 |
+
# auto-generate quiz via backend LLM
|
| 237 |
+
if gen_quiz:
|
| 238 |
+
text = "\n\n".join([s["title"] + "\n" + (s["content"] or "") for s in sections])
|
| 239 |
+
with st.spinner("Generating quiz from lesson content..."):
|
| 240 |
+
items = _generate_quiz_from_text(text, n_questions=q_count, subject=subject, level=level)
|
| 241 |
+
if items:
|
| 242 |
+
try:
|
| 243 |
+
qid = _create_quiz(lesson_id, f"{title} - Quiz", items, {})
|
| 244 |
+
st.success(f"🧠 Quiz generated and saved (ID {qid}).")
|
| 245 |
+
for label in assign_classes:
|
| 246 |
+
_assign_to_class(lesson_id, qid, class_opts[label], teacher_id)
|
| 247 |
+
except Exception as e:
|
| 248 |
+
st.warning(f"Lesson saved, but failed to save quiz: {e}")
|
| 249 |
+
|
| 250 |
+
st.session_state.show_create_lesson = False
|
| 251 |
+
st.rerun()
|
| 252 |
+
|
| 253 |
+
def _create_quiz_panel(teacher_id: int):
|
| 254 |
+
st.markdown("### 🏆 Create New Quiz")
|
| 255 |
+
|
| 256 |
+
lessons = _list_lessons_by_teacher(teacher_id)
|
| 257 |
+
lesson_map = {f"{L['title']} (#{L['lesson_id']})": L["lesson_id"] for L in lessons}
|
| 258 |
+
if not lesson_map:
|
| 259 |
+
st.info("Create a lesson first, then link a quiz to it.")
|
| 260 |
+
return
|
| 261 |
+
|
| 262 |
+
if "cq_q_count" not in st.session_state:
|
| 263 |
+
st.session_state.cq_q_count = 5
|
| 264 |
+
|
| 265 |
+
with st.form("create_quiz_form", clear_on_submit=False):
|
| 266 |
+
c1, c2 = st.columns([2,1])
|
| 267 |
+
title = c1.text_input("Title", placeholder="e.g., Currency Basics Quiz")
|
| 268 |
+
lesson_label = c2.selectbox("Linked Lesson", list(lesson_map.keys()))
|
| 269 |
+
|
| 270 |
+
st.markdown("#### Questions (up to 10)")
|
| 271 |
+
items = []
|
| 272 |
+
for i in range(1, st.session_state.cq_q_count + 1):
|
| 273 |
+
with st.expander(f"Question {i}", expanded=(i <= 2)):
|
| 274 |
+
q = st.text_area(f"Prompt {i}", key=f"q_{i}")
|
| 275 |
+
cA, cB = st.columns(2)
|
| 276 |
+
a = cA.text_input(f"Option A (correct?)", key=f"optA_{i}")
|
| 277 |
+
b = cB.text_input(f"Option B", key=f"optB_{i}")
|
| 278 |
+
cC, cD = st.columns(2)
|
| 279 |
+
c = cC.text_input(f"Option C", key=f"optC_{i}")
|
| 280 |
+
d = cD.text_input(f"Option D", key=f"optD_{i}")
|
| 281 |
+
correct = st.radio("Correct answer", ["A","B","C","D"], index=0, key=f"ans_{i}", horizontal=True)
|
| 282 |
+
items.append({"question": q, "options": [a,b,c,d], "answer_key": correct, "points": 1})
|
| 283 |
+
|
| 284 |
+
row = st.columns([1,1,4,2])
|
| 285 |
+
with row[0]:
|
| 286 |
+
if st.form_submit_button("➕ Add question", type="secondary", disabled=st.session_state.cq_q_count >= 10):
|
| 287 |
+
st.session_state.cq_q_count = min(10, st.session_state.cq_q_count + 1)
|
| 288 |
+
st.rerun()
|
| 289 |
+
with row[1]:
|
| 290 |
+
if st.form_submit_button("➖ Remove last", type="secondary", disabled=st.session_state.cq_q_count <= 1):
|
| 291 |
+
st.session_state.cq_q_count = max(1, st.session_state.cq_q_count - 1)
|
| 292 |
+
st.rerun()
|
| 293 |
+
|
| 294 |
+
submitted = row[3].form_submit_button("Create quiz", type="primary")
|
| 295 |
+
|
| 296 |
+
if not submitted:
|
| 297 |
+
return
|
| 298 |
+
if not title:
|
| 299 |
+
st.error("Please add a quiz title.")
|
| 300 |
+
return
|
| 301 |
+
|
| 302 |
+
cleaned = []
|
| 303 |
+
for it in items:
|
| 304 |
+
q = (it["question"] or "").strip()
|
| 305 |
+
opts = [o for o in it["options"] if (o or "").strip()]
|
| 306 |
+
if len(opts) < 2 or not q:
|
| 307 |
+
continue
|
| 308 |
+
while len(opts) < 4:
|
| 309 |
+
opts.append("Option")
|
| 310 |
+
cleaned.append({"question": q, "options": opts[:4], "answer_key": it["answer_key"], "points": 1})
|
| 311 |
+
|
| 312 |
+
if not cleaned:
|
| 313 |
+
st.error("Add at least one valid question.")
|
| 314 |
+
return
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
qid = _create_quiz(lesson_map[lesson_label], title, cleaned, {})
|
| 318 |
+
st.success(f"✅ Quiz created (ID {qid}).")
|
| 319 |
+
st.session_state.show_create_quiz = False
|
| 320 |
+
st.rerun()
|
| 321 |
+
except Exception as e:
|
| 322 |
+
st.error(f"Failed to create quiz: {e}")
|
| 323 |
+
|
| 324 |
+
def _edit_lesson_panel(teacher_id: int, lesson_id: int):
|
| 325 |
+
try:
|
| 326 |
+
data = _get_lesson(lesson_id)
|
| 327 |
+
except Exception as e:
|
| 328 |
+
st.error(f"Could not load lesson #{lesson_id}: {e}")
|
| 329 |
+
return
|
| 330 |
+
|
| 331 |
+
L = data.get("lesson", {})
|
| 332 |
+
secs = data.get("sections", []) or []
|
| 333 |
+
|
| 334 |
+
key_cnt = f"el_cnt_{lesson_id}"
|
| 335 |
+
if key_cnt not in st.session_state:
|
| 336 |
+
st.session_state[key_cnt] = max(1, len(secs))
|
| 337 |
+
|
| 338 |
+
st.markdown("### ✏️ Edit Lesson")
|
| 339 |
+
|
| 340 |
+
tools = st.columns([1,1,8])
|
| 341 |
+
with tools[0]:
|
| 342 |
+
if st.button("➕ Add section", key=f"el_add_{lesson_id}", use_container_width=True):
|
| 343 |
+
st.session_state[key_cnt] = min(50, st.session_state[key_cnt] + 1)
|
| 344 |
+
st.rerun()
|
| 345 |
+
with tools[1]:
|
| 346 |
+
if st.button("➖ Remove last", key=f"el_rem_{lesson_id}",
|
| 347 |
+
disabled=st.session_state[key_cnt] <= 1, use_container_width=True):
|
| 348 |
+
st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
|
| 349 |
+
st.rerun()
|
| 350 |
+
|
| 351 |
+
with st.form(f"edit_lesson_form_{lesson_id}", clear_on_submit=False):
|
| 352 |
+
c1, c2 = st.columns([2,1])
|
| 353 |
+
title = c1.text_input("Title", value=L.get("title") or "")
|
| 354 |
+
level = c2.selectbox(
|
| 355 |
+
"Level",
|
| 356 |
+
["beginner","intermediate","advanced"],
|
| 357 |
+
index=["beginner","intermediate","advanced"].index(L.get("level") or "beginner")
|
| 358 |
+
)
|
| 359 |
+
description = st.text_area("Short description", value=L.get("description") or "")
|
| 360 |
+
subject = st.selectbox("Subject", ["numeracy","finance"], index=(0 if (L.get("subject")=="numeracy") else 1))
|
| 361 |
+
|
| 362 |
+
st.markdown("#### Sections")
|
| 363 |
+
edited_sections = []
|
| 364 |
+
total = st.session_state[key_cnt]
|
| 365 |
+
for i in range(1, total + 1):
|
| 366 |
+
s = secs[i-1] if i-1 < len(secs) else {"title":"", "content":""}
|
| 367 |
+
with st.expander(f"Section {i}", expanded=(i <= 2)):
|
| 368 |
+
t = st.text_input(f"Title {i}", value=s.get("title") or "", key=f"el_t_{lesson_id}_{i}")
|
| 369 |
+
b = st.text_area(f"Content {i}", value=s.get("content") or "", height=150, key=f"el_b_{lesson_id}_{i}")
|
| 370 |
+
edited_sections.append({"title": t or "Section", "content": b or ""})
|
| 371 |
+
|
| 372 |
+
save = st.form_submit_button("💾 Save changes", type="primary", use_container_width=True)
|
| 373 |
+
|
| 374 |
+
actions = st.columns([8,2])
|
| 375 |
+
with actions[1]:
|
| 376 |
+
cancel_clicked = st.button("✖ Cancel", key=f"el_cancel_{lesson_id}", type="secondary", use_container_width=True)
|
| 377 |
+
|
| 378 |
+
if cancel_clicked:
|
| 379 |
+
st.session_state.show_edit_lesson = False
|
| 380 |
+
st.session_state.edit_lesson_id = None
|
| 381 |
+
st.rerun()
|
| 382 |
+
|
| 383 |
+
if not save:
|
| 384 |
+
return
|
| 385 |
+
|
| 386 |
+
if not title or not any((s["title"] or s["content"]).strip() for s in edited_sections):
|
| 387 |
+
st.error("Title and at least one non-empty section are required.")
|
| 388 |
+
return
|
| 389 |
+
|
| 390 |
+
ok = False
|
| 391 |
+
try:
|
| 392 |
+
ok = _update_lesson(lesson_id, teacher_id, title, description, subject, level, edited_sections)
|
| 393 |
+
except Exception as e:
|
| 394 |
+
st.error(f"Update failed: {e}")
|
| 395 |
+
|
| 396 |
+
if ok:
|
| 397 |
+
st.success("✅ Lesson updated.")
|
| 398 |
+
st.session_state.show_edit_lesson = False
|
| 399 |
+
st.session_state.edit_lesson_id = None
|
| 400 |
+
st.rerun()
|
| 401 |
+
else:
|
| 402 |
+
st.error("Could not update this lesson. Check ownership or backend errors.")
|
| 403 |
+
|
| 404 |
+
def _edit_quiz_panel(teacher_id: int, quiz_id: int):
|
| 405 |
+
# Load quiz
|
| 406 |
+
try:
|
| 407 |
+
data = (dbapi.get_quiz(quiz_id) if (USE_LOCAL_DB and hasattr(dbapi, "get_quiz")) else api._req("GET", f"/quizzes/{quiz_id}").json())
|
| 408 |
+
except Exception as e:
|
| 409 |
+
st.error(f"Quiz not found: {e}")
|
| 410 |
+
return
|
| 411 |
+
|
| 412 |
+
Q = data.get("quiz")
|
| 413 |
+
raw_items = data.get("items", [])
|
| 414 |
+
if not Q:
|
| 415 |
+
st.error("Quiz not found.")
|
| 416 |
+
return
|
| 417 |
+
|
| 418 |
+
def _dec(x):
|
| 419 |
+
if isinstance(x, str):
|
| 420 |
+
try:
|
| 421 |
+
return json.loads(x)
|
| 422 |
+
except Exception:
|
| 423 |
+
return x
|
| 424 |
+
return x
|
| 425 |
+
|
| 426 |
+
items = []
|
| 427 |
+
for it in raw_items:
|
| 428 |
+
opts = _dec(it.get("options")) or []
|
| 429 |
+
while len(opts) < 4:
|
| 430 |
+
opts.append("Option")
|
| 431 |
+
opts = opts[:4]
|
| 432 |
+
|
| 433 |
+
ans = _dec(it.get("answer_key"))
|
| 434 |
+
if isinstance(ans, list) and ans:
|
| 435 |
+
ans = ans[0]
|
| 436 |
+
ans = (str(ans) or "A").upper()[:1]
|
| 437 |
+
if ans not in ("A","B","C","D"):
|
| 438 |
+
ans = "A"
|
| 439 |
+
|
| 440 |
+
items.append({
|
| 441 |
+
"question": (it.get("question") or "").strip(),
|
| 442 |
+
"options": opts,
|
| 443 |
+
"answer_key": ans,
|
| 444 |
+
"points": int(it.get("points") or 1),
|
| 445 |
+
})
|
| 446 |
+
|
| 447 |
+
key_cnt = f"eq_cnt_{quiz_id}"
|
| 448 |
+
if key_cnt not in st.session_state:
|
| 449 |
+
st.session_state[key_cnt] = max(1, len(items) or 5)
|
| 450 |
+
|
| 451 |
+
st.markdown("### ✏️ Edit Quiz")
|
| 452 |
+
|
| 453 |
+
with st.form(f"edit_quiz_form_{quiz_id}", clear_on_submit=False):
|
| 454 |
+
title = st.text_input("Title", value=Q.get("title") or f"Quiz #{quiz_id}")
|
| 455 |
+
|
| 456 |
+
edited = []
|
| 457 |
+
total = st.session_state[key_cnt]
|
| 458 |
+
for i in range(1, total + 1):
|
| 459 |
+
it = items[i-1] if i-1 < len(items) else {"question":"", "options":["","","",""], "answer_key":"A", "points":1}
|
| 460 |
+
with st.expander(f"Question {i}", expanded=(i <= 2)):
|
| 461 |
+
q = st.text_area(f"Prompt {i}", value=it["question"], key=f"eq_q_{quiz_id}_{i}")
|
| 462 |
+
cA, cB = st.columns(2)
|
| 463 |
+
a = cA.text_input(f"Option A", value=it["options"][0], key=f"eq_A_{quiz_id}_{i}")
|
| 464 |
+
b = cB.text_input(f"Option B", value=it["options"][1], key=f"eq_B_{quiz_id}_{i}")
|
| 465 |
+
cC, cD = st.columns(2)
|
| 466 |
+
c = cC.text_input(f"Option C", value=it["options"][2], key=f"eq_C_{quiz_id}_{i}")
|
| 467 |
+
d = cD.text_input(f"Option D", value=it["options"][3], key=f"eq_D_{quiz_id}_{i}")
|
| 468 |
+
correct = st.radio("Correct answer", ["A","B","C","D"],
|
| 469 |
+
index=["A","B","C","D"].index(it["answer_key"]),
|
| 470 |
+
key=f"eq_ans_{quiz_id}_{i}", horizontal=True)
|
| 471 |
+
edited.append({"question": q, "options": [a,b,c,d], "answer_key": correct, "points": 1})
|
| 472 |
+
|
| 473 |
+
row = st.columns([1,1,6,2,2])
|
| 474 |
+
with row[0]:
|
| 475 |
+
if st.form_submit_button("➕ Add question", type="secondary"):
|
| 476 |
+
st.session_state[key_cnt] = min(20, st.session_state[key_cnt] + 1)
|
| 477 |
+
st.rerun()
|
| 478 |
+
with row[1]:
|
| 479 |
+
if st.form_submit_button("➖ Remove last", type="secondary", disabled=st.session_state[key_cnt] <= 1):
|
| 480 |
+
st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
|
| 481 |
+
st.rerun()
|
| 482 |
+
|
| 483 |
+
save = row[3].form_submit_button("💾 Save", type="primary")
|
| 484 |
+
cancel = row[4].form_submit_button("✖ Cancel", type="secondary")
|
| 485 |
+
|
| 486 |
+
if cancel:
|
| 487 |
+
st.session_state.show_edit_quiz = False
|
| 488 |
+
st.session_state.edit_quiz_id = None
|
| 489 |
+
st.rerun()
|
| 490 |
+
|
| 491 |
+
if not save:
|
| 492 |
+
return
|
| 493 |
+
|
| 494 |
+
cleaned = []
|
| 495 |
+
for it in edited:
|
| 496 |
+
q = (it["question"] or "").strip()
|
| 497 |
+
opts = [o for o in it["options"] if (o or "").strip()]
|
| 498 |
+
if not q or len(opts) < 2:
|
| 499 |
+
continue
|
| 500 |
+
while len(opts) < 4:
|
| 501 |
+
opts.append("Option")
|
| 502 |
+
cleaned.append({
|
| 503 |
+
"question": q,
|
| 504 |
+
"options": opts[:4],
|
| 505 |
+
"answer_key": it["answer_key"],
|
| 506 |
+
"points": 1
|
| 507 |
+
})
|
| 508 |
+
|
| 509 |
+
if not title or not cleaned:
|
| 510 |
+
st.error("Title and at least one valid question are required.")
|
| 511 |
+
return
|
| 512 |
+
|
| 513 |
+
ok = False
|
| 514 |
+
try:
|
| 515 |
+
ok = _update_quiz(quiz_id, teacher_id, title, cleaned, settings={})
|
| 516 |
+
except Exception as e:
|
| 517 |
+
st.error(f"Save failed: {e}")
|
| 518 |
+
|
| 519 |
+
if ok:
|
| 520 |
+
st.success("✅ Quiz updated.")
|
| 521 |
+
st.session_state.show_edit_quiz = False
|
| 522 |
+
st.session_state.edit_quiz_id = None
|
| 523 |
+
st.rerun()
|
| 524 |
+
else:
|
| 525 |
+
st.error("Could not update this quiz. Check ownership or backend errors.")
|
| 526 |
+
|
| 527 |
+
# ---------- Main page ----------
|
| 528 |
+
def show_page():
|
| 529 |
+
user = st.session_state.user
|
| 530 |
+
teacher_id = user["user_id"]
|
| 531 |
+
|
| 532 |
+
st.title("📚 Content Management")
|
| 533 |
+
st.caption("Create and manage custom lessons and quizzes")
|
| 534 |
+
|
| 535 |
+
# preload lists
|
| 536 |
+
lessons = _list_lessons_by_teacher(teacher_id)
|
| 537 |
+
quizzes = _list_quizzes_by_teacher(teacher_id)
|
| 538 |
+
|
| 539 |
+
# top action bar
|
| 540 |
+
a1, a2, _sp = st.columns([3,3,4])
|
| 541 |
+
if a1.button("➕ Create Lesson", use_container_width=True):
|
| 542 |
+
st.session_state.show_create_lesson = True
|
| 543 |
+
if a2.button("🏆 Create Quiz", use_container_width=True):
|
| 544 |
+
st.session_state.show_create_quiz = True
|
| 545 |
+
|
| 546 |
+
# create panels
|
| 547 |
+
if st.session_state.get("show_create_lesson"):
|
| 548 |
+
with st.container(border=True):
|
| 549 |
+
_create_lesson_panel(teacher_id)
|
| 550 |
+
st.markdown("---")
|
| 551 |
+
|
| 552 |
+
if st.session_state.get("show_create_quiz"):
|
| 553 |
+
with st.container(border=True):
|
| 554 |
+
_create_quiz_panel(teacher_id)
|
| 555 |
+
st.markdown("---")
|
| 556 |
+
|
| 557 |
+
# inline editors
|
| 558 |
+
if st.session_state.get("show_edit_lesson") and st.session_state.get("edit_lesson_id"):
|
| 559 |
+
with st.container(border=True):
|
| 560 |
+
_edit_lesson_panel(teacher_id, st.session_state.edit_lesson_id)
|
| 561 |
+
st.markdown("---")
|
| 562 |
+
|
| 563 |
+
if st.session_state.get("show_edit_quiz") and st.session_state.get("edit_quiz_id"):
|
| 564 |
+
with st.container(border=True):
|
| 565 |
+
_edit_quiz_panel(teacher_id, st.session_state.edit_quiz_id)
|
| 566 |
+
st.markdown("---")
|
| 567 |
+
|
| 568 |
+
# Tabs
|
| 569 |
+
tab1, tab2 = st.tabs([f"Custom Lessons ({len(lessons)})", f"Custom Quizzes ({len(quizzes)})"])
|
| 570 |
+
|
| 571 |
+
# ========== LESSONS ==========
|
| 572 |
+
with tab1:
|
| 573 |
+
if not lessons:
|
| 574 |
+
st.info("No lessons yet. Use **Create Lesson** above.")
|
| 575 |
+
else:
|
| 576 |
+
all_students = _list_all_students_for_teacher(teacher_id)
|
| 577 |
+
student_options = {f"{s['name']} · {s['email']}": s["user_id"] for s in all_students}
|
| 578 |
+
|
| 579 |
+
for L in lessons:
|
| 580 |
+
assignees = _list_assigned_students_for_lesson(L["lesson_id"])
|
| 581 |
+
assignee_names = [a.get("name") for a in assignees]
|
| 582 |
+
created = _fmt_date(L.get("created_at"))
|
| 583 |
+
count = len(assignees)
|
| 584 |
+
|
| 585 |
+
with st.container(border=True):
|
| 586 |
+
c1, c2 = st.columns([8,3])
|
| 587 |
+
with c1:
|
| 588 |
+
st.markdown(f"### {L['title']}")
|
| 589 |
+
st.caption(L.get("description") or "")
|
| 590 |
+
st.markdown(
|
| 591 |
+
_pill((L.get("level") or "beginner").capitalize()) +
|
| 592 |
+
_pill(L.get("subject","finance")) +
|
| 593 |
+
_pill(f"{count} student{'s' if count != 1 else ''} assigned") +
|
| 594 |
+
_pill(f"Created {created}"),
|
| 595 |
+
unsafe_allow_html=True
|
| 596 |
+
)
|
| 597 |
+
with c2:
|
| 598 |
+
b1, b2 = st.columns([1,1])
|
| 599 |
+
with b1:
|
| 600 |
+
if st.button("Edit", key=f"edit_{L['lesson_id']}"):
|
| 601 |
+
st.session_state.edit_lesson_id = L["lesson_id"]
|
| 602 |
+
st.session_state.show_edit_lesson = True
|
| 603 |
+
st.rerun()
|
| 604 |
+
with b2:
|
| 605 |
+
if st.button("Delete", key=f"del_{L['lesson_id']}"):
|
| 606 |
+
ok, msg = _delete_lesson(L["lesson_id"], teacher_id)
|
| 607 |
+
if ok: st.success("Lesson deleted"); st.rerun()
|
| 608 |
+
else: st.error(msg or "Delete failed")
|
| 609 |
+
|
| 610 |
+
st.markdown("**Assigned Students:**")
|
| 611 |
+
if assignee_names:
|
| 612 |
+
st.markdown(" ".join(_pill(n) for n in assignee_names if n), unsafe_allow_html=True)
|
| 613 |
+
else:
|
| 614 |
+
st.caption("No students assigned yet.")
|
| 615 |
+
|
| 616 |
+
# ========== QUIZZES ==========
|
| 617 |
+
with tab2:
|
| 618 |
+
if not quizzes:
|
| 619 |
+
st.info("No quizzes yet. Use **Create Quiz** above.")
|
| 620 |
+
else:
|
| 621 |
+
for Q in quizzes:
|
| 622 |
+
assignees = _list_assigned_students_for_quiz(Q["quiz_id"])
|
| 623 |
+
created = _fmt_date(Q.get("created_at"))
|
| 624 |
+
num_qs = int(Q.get("num_items", 0))
|
| 625 |
+
|
| 626 |
+
with st.container(border=True):
|
| 627 |
+
c1, c2 = st.columns([8,3])
|
| 628 |
+
with c1:
|
| 629 |
+
st.markdown(f"### {Q['title']}")
|
| 630 |
+
st.caption(f"Lesson: {Q.get('lesson_title','')}")
|
| 631 |
+
st.markdown(
|
| 632 |
+
_pill(f"{num_qs} question{'s' if num_qs != 1 else ''}") +
|
| 633 |
+
_pill(f"{len(assignees)} students assigned") +
|
| 634 |
+
_pill(f"Created {created}"),
|
| 635 |
+
unsafe_allow_html=True
|
| 636 |
+
)
|
| 637 |
+
with c2:
|
| 638 |
+
b1, b2 = st.columns(2)
|
| 639 |
+
with b1:
|
| 640 |
+
if st.button("Edit", key=f"editq_{Q['quiz_id']}"):
|
| 641 |
+
st.session_state.edit_quiz_id = Q["quiz_id"]
|
| 642 |
+
st.session_state.show_edit_quiz = True
|
| 643 |
+
st.rerun()
|
| 644 |
+
with b2:
|
| 645 |
+
if st.button("Delete", key=f"delq_{Q['quiz_id']}"):
|
| 646 |
+
ok, msg = _delete_quiz(Q["quiz_id"], teacher_id)
|
| 647 |
+
if ok: st.success("Quiz deleted"); st.rerun()
|
| 648 |
+
else: st.error(msg or "Delete failed")
|
| 649 |
+
|
| 650 |
+
st.markdown("**Assigned Students:**")
|
| 651 |
+
if assignees:
|
| 652 |
+
st.markdown(" ".join(_pill(a.get('name')) for a in assignees if a.get('name')), unsafe_allow_html=True)
|
| 653 |
+
else:
|
| 654 |
+
st.caption("No students assigned yet.")
|
| 655 |
+
|
| 656 |
+
with st.expander("View questions", expanded=False):
|
| 657 |
+
# Load items on demand to avoid heavy initial load
|
| 658 |
+
try:
|
| 659 |
+
data = (dbapi.get_quiz(Q["quiz_id"]) if (USE_LOCAL_DB and hasattr(dbapi, "get_quiz"))
|
| 660 |
+
else api._req("GET", f"/quizzes/{Q['quiz_id']}").json())
|
| 661 |
+
except Exception as e:
|
| 662 |
+
st.info(f"Could not fetch items: {e}")
|
| 663 |
+
data = None
|
| 664 |
+
items = data.get("items", []) if data else []
|
| 665 |
+
if not items:
|
| 666 |
+
st.info("No items found for this quiz.")
|
| 667 |
+
else:
|
| 668 |
+
labels = ["A","B","C","D"]
|
| 669 |
+
for i, it in enumerate(items, start=1):
|
| 670 |
+
opts = it.get("options")
|
| 671 |
+
if isinstance(opts, str):
|
| 672 |
+
try:
|
| 673 |
+
opts = json.loads(opts)
|
| 674 |
+
except Exception:
|
| 675 |
+
opts = [opts]
|
| 676 |
+
answer = it.get("answer_key")
|
| 677 |
+
if isinstance(answer, str):
|
| 678 |
+
try:
|
| 679 |
+
answer = json.loads(answer)
|
| 680 |
+
except Exception:
|
| 681 |
+
pass
|
| 682 |
+
|
| 683 |
+
st.markdown(f"**Q{i}.** {it.get('question','').strip()}")
|
| 684 |
+
for j, opt in enumerate((opts or [])[:4]):
|
| 685 |
+
st.write(f"{labels[j]}) {opt}")
|
| 686 |
+
ans_text = answer if isinstance(answer, str) else ",".join(answer or [])
|
| 687 |
+
st.caption(f"Answer: {ans_text}")
|
| 688 |
+
st.markdown("---")
|
phase/Teacher_view/studentlist.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# phase/Teacher_view/studentlist.py
|
| 2 |
+
import os
|
| 3 |
+
import streamlit as st
|
| 4 |
+
from utils import db as dbapi
|
| 5 |
+
import utils.api as api # backend Space client
|
| 6 |
+
|
| 7 |
+
# Use local DB only when DISABLE_DB != "1"
|
| 8 |
+
USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
|
| 9 |
+
|
| 10 |
+
# ---------- tiny helpers ----------
|
| 11 |
+
def _avatar(name: str) -> str:
|
| 12 |
+
return "🧑🎓" if hash(name) % 2 else "👩🎓"
|
| 13 |
+
|
| 14 |
+
def _avg_pct_from_row(r) -> int:
|
| 15 |
+
"""
|
| 16 |
+
Accepts either:
|
| 17 |
+
- r['avg_pct'] in [0, 100]
|
| 18 |
+
- r['avg_score'] in [0, 1] or [0, 100]
|
| 19 |
+
Returns an int 0..100.
|
| 20 |
+
"""
|
| 21 |
+
v = r.get("avg_pct", r.get("avg_score", 0)) or 0
|
| 22 |
+
try:
|
| 23 |
+
f = float(v)
|
| 24 |
+
if f <= 1.0: # treat as 0..1
|
| 25 |
+
f *= 100.0
|
| 26 |
+
return max(0, min(100, int(round(f))))
|
| 27 |
+
except Exception:
|
| 28 |
+
return 0
|
| 29 |
+
|
| 30 |
+
def _level_from_xp(total_xp: int) -> int:
|
| 31 |
+
try:
|
| 32 |
+
xp = int(total_xp or 0)
|
| 33 |
+
except Exception:
|
| 34 |
+
xp = 0
|
| 35 |
+
return 1 + xp // 500
|
| 36 |
+
|
| 37 |
+
def _report_text(r, level, avg_pct):
|
| 38 |
+
return (
|
| 39 |
+
"STUDENT PROGRESS REPORT\n"
|
| 40 |
+
"======================\n"
|
| 41 |
+
f"Student: {r.get('name','')}\n"
|
| 42 |
+
f"Email: {r.get('email','')}\n"
|
| 43 |
+
f"Joined: {str(r.get('joined_at',''))[:10]}\n\n"
|
| 44 |
+
"PROGRESS OVERVIEW\n"
|
| 45 |
+
"-----------------\n"
|
| 46 |
+
f"Lessons Completed: {int(r.get('lessons_completed') or 0)}/{int(r.get('total_assigned_lessons') or 0)}\n"
|
| 47 |
+
f"Average Quiz Score: {avg_pct}%\n"
|
| 48 |
+
f"Total XP: {int(r.get('total_xp') or 0)}\n"
|
| 49 |
+
f"Current Level: {level}\n"
|
| 50 |
+
f"Study Streak: {int(r.get('streak_days') or 0)} days\n"
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
ROW_CSS = """
|
| 54 |
+
<style>
|
| 55 |
+
.sm-chip{display:inline-block;padding:4px 10px;border-radius:999px;background:#eef7f1;color:#0b8f5d;font-weight:600;font-size:.80rem;margin-left:8px}
|
| 56 |
+
.sm-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid #e6e6e6;border-radius:8px;background:#fff}
|
| 57 |
+
.sm-row{border:1px solid #eee;border-radius:12px;padding:16px 16px;margin:10px 0;background:#fff}
|
| 58 |
+
.sm-row:hover{box-shadow:0 2px 10px rgba(0,0,0,.04)}
|
| 59 |
+
.sm-right{display:flex;gap:16px;align-items:center;justify-content:flex-end}
|
| 60 |
+
.sm-metric{min-width:90px;text-align:right}
|
| 61 |
+
.sm-metric .label{color:#777;font-size:.75rem}
|
| 62 |
+
.sm-metric .value{font-weight:700;font-size:1.1rem}
|
| 63 |
+
.sm-name{font-size:1.05rem;font-weight:700}
|
| 64 |
+
.sm-sub{color:#6c6c6c;font-size:.85rem}
|
| 65 |
+
</style>
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
# ---------- data access (DB or Backend) ----------
|
| 69 |
+
@st.cache_data(show_spinner=False, ttl=30)
|
| 70 |
+
def _list_classes_by_teacher(teacher_id: int):
|
| 71 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"):
|
| 72 |
+
return dbapi.list_classes_by_teacher(teacher_id) or []
|
| 73 |
+
try:
|
| 74 |
+
return api.list_classes_by_teacher(teacher_id) or []
|
| 75 |
+
except Exception:
|
| 76 |
+
return []
|
| 77 |
+
|
| 78 |
+
@st.cache_data(show_spinner=False, ttl=30)
|
| 79 |
+
def _get_class(class_id: int):
|
| 80 |
+
if USE_LOCAL_DB and hasattr(dbapi, "get_class"):
|
| 81 |
+
return dbapi.get_class(class_id) or {}
|
| 82 |
+
try:
|
| 83 |
+
return api.get_class(class_id) or {}
|
| 84 |
+
except Exception:
|
| 85 |
+
return {}
|
| 86 |
+
|
| 87 |
+
@st.cache_data(show_spinner=False, ttl=30)
|
| 88 |
+
def _class_student_metrics(class_id: int):
|
| 89 |
+
if USE_LOCAL_DB and hasattr(dbapi, "class_student_metrics"):
|
| 90 |
+
return dbapi.class_student_metrics(class_id) or []
|
| 91 |
+
try:
|
| 92 |
+
return api.class_student_metrics(class_id) or []
|
| 93 |
+
except Exception:
|
| 94 |
+
return []
|
| 95 |
+
|
| 96 |
+
@st.cache_data(show_spinner=False, ttl=30)
|
| 97 |
+
def _list_assignments_for_student(student_id: int):
|
| 98 |
+
if USE_LOCAL_DB and hasattr(dbapi, "list_assignments_for_student"):
|
| 99 |
+
return dbapi.list_assignments_for_student(student_id) or []
|
| 100 |
+
try:
|
| 101 |
+
return api.list_assignments_for_student(student_id) or []
|
| 102 |
+
except Exception:
|
| 103 |
+
return []
|
| 104 |
+
|
| 105 |
+
# ---------- page ----------
|
| 106 |
+
def show_page():
|
| 107 |
+
st.title("🎓 Student Management")
|
| 108 |
+
st.caption("Monitor and manage your students' progress")
|
| 109 |
+
st.markdown(ROW_CSS, unsafe_allow_html=True)
|
| 110 |
+
|
| 111 |
+
teacher = st.session_state.get("user")
|
| 112 |
+
if not teacher:
|
| 113 |
+
st.error("Please log in.")
|
| 114 |
+
return
|
| 115 |
+
teacher_id = teacher["user_id"]
|
| 116 |
+
|
| 117 |
+
classes = _list_classes_by_teacher(teacher_id)
|
| 118 |
+
if not classes:
|
| 119 |
+
st.info("No classes yet. Create one in Classroom Management.")
|
| 120 |
+
return
|
| 121 |
+
|
| 122 |
+
# class selector
|
| 123 |
+
idx = st.selectbox(
|
| 124 |
+
"Choose a class",
|
| 125 |
+
list(range(len(classes))),
|
| 126 |
+
index=0,
|
| 127 |
+
format_func=lambda i: f"{classes[i].get('name','(unnamed)')}"
|
| 128 |
+
)
|
| 129 |
+
selected = classes[idx]
|
| 130 |
+
class_id = selected.get("class_id") or selected.get("id") # be tolerant to backend naming
|
| 131 |
+
if class_id is None:
|
| 132 |
+
st.error("Selected class is missing an ID.")
|
| 133 |
+
return
|
| 134 |
+
|
| 135 |
+
code_row = _get_class(class_id)
|
| 136 |
+
|
| 137 |
+
# get students before drawing chips
|
| 138 |
+
rows = _class_student_metrics(class_id)
|
| 139 |
+
|
| 140 |
+
# code + student chip row
|
| 141 |
+
chip1, chip2 = st.columns([1, 1])
|
| 142 |
+
with chip1:
|
| 143 |
+
st.markdown(
|
| 144 |
+
f'<div class="sm-chip">Code: {code_row.get("code","")}</div>',
|
| 145 |
+
unsafe_allow_html=True
|
| 146 |
+
)
|
| 147 |
+
with chip2:
|
| 148 |
+
st.markdown(
|
| 149 |
+
f'<div class="sm-chip">👥 {len(rows)} Students</div>',
|
| 150 |
+
unsafe_allow_html=True
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
st.markdown("---")
|
| 154 |
+
|
| 155 |
+
# search line
|
| 156 |
+
query = st.text_input(
|
| 157 |
+
"Search students by name or email",
|
| 158 |
+
placeholder="Type a name or email..."
|
| 159 |
+
).strip().lower()
|
| 160 |
+
|
| 161 |
+
if query:
|
| 162 |
+
rows = [
|
| 163 |
+
r for r in rows
|
| 164 |
+
if query in (r.get("name","").lower()) or query in (r.get("email","").lower())
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
# student rows
|
| 168 |
+
for r in rows:
|
| 169 |
+
name = r.get("name", "Unknown")
|
| 170 |
+
email = r.get("email", "")
|
| 171 |
+
joined = str(r.get("joined_at", ""))[:10]
|
| 172 |
+
total_xp = int(r.get("total_xp") or 0)
|
| 173 |
+
level = _level_from_xp(total_xp)
|
| 174 |
+
lessons_completed = int(r.get("lessons_completed") or 0)
|
| 175 |
+
total_assigned = int(r.get("total_assigned_lessons") or 0)
|
| 176 |
+
avg_pct = _avg_pct_from_row(r)
|
| 177 |
+
streak = int(r.get("streak_days") or 0)
|
| 178 |
+
student_id = r.get("student_id") or r.get("id")
|
| 179 |
+
|
| 180 |
+
with st.container():
|
| 181 |
+
st.markdown('<div class="sm-row">', unsafe_allow_html=True)
|
| 182 |
+
|
| 183 |
+
# top bar: avatar + name/email + right metrics
|
| 184 |
+
a, b, c = st.columns([0.7, 4, 3])
|
| 185 |
+
with a:
|
| 186 |
+
st.markdown(f"### {_avatar(name)}")
|
| 187 |
+
with b:
|
| 188 |
+
st.markdown(f'<div class="sm-name">{name}</div>', unsafe_allow_html=True)
|
| 189 |
+
st.markdown(f'<div class="sm-sub">{email} · Joined {joined}</div>', unsafe_allow_html=True)
|
| 190 |
+
with c:
|
| 191 |
+
st.markdown(
|
| 192 |
+
'<div class="sm-right">'
|
| 193 |
+
f'<div class="sm-metric"><div class="value">{level}</div><div class="label">Level</div></div>'
|
| 194 |
+
f'<div class="sm-metric"><div class="value">{avg_pct}%</div><div class="label">Avg Score</div></div>'
|
| 195 |
+
f'<div class="sm-metric"><div class="value">{streak}</div><div class="label">Streak</div></div>'
|
| 196 |
+
"</div>",
|
| 197 |
+
unsafe_allow_html=True
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# progress bar
|
| 201 |
+
st.caption("Overall Progress")
|
| 202 |
+
frac = (lessons_completed / total_assigned) if total_assigned > 0 else 0.0
|
| 203 |
+
st.progress(min(1.0, frac))
|
| 204 |
+
st.caption(f"{lessons_completed}/{total_assigned} lessons")
|
| 205 |
+
|
| 206 |
+
# actions row
|
| 207 |
+
d1, d2, spacer = st.columns([2, 1.3, 5])
|
| 208 |
+
with d1:
|
| 209 |
+
with st.popover("👁️ View Details"):
|
| 210 |
+
if student_id is not None:
|
| 211 |
+
items = _list_assignments_for_student(int(student_id))
|
| 212 |
+
else:
|
| 213 |
+
items = []
|
| 214 |
+
if items:
|
| 215 |
+
for it in items[:25]:
|
| 216 |
+
tag = " + Quiz" if it.get("quiz_id") else ""
|
| 217 |
+
st.markdown(
|
| 218 |
+
f"- **{it.get('title','Untitled')}** · {it.get('subject','General')} · "
|
| 219 |
+
f"{it.get('level','')} {tag} · Status: {it.get('status','unknown')}"
|
| 220 |
+
)
|
| 221 |
+
else:
|
| 222 |
+
st.info("No assignments yet.")
|
| 223 |
+
with d2:
|
| 224 |
+
rep = _report_text(r, level, avg_pct)
|
| 225 |
+
st.download_button(
|
| 226 |
+
"⬇️ Export",
|
| 227 |
+
data=rep,
|
| 228 |
+
file_name=f"{str(name).replace(' ','_')}_report.txt",
|
| 229 |
+
mime="text/plain",
|
| 230 |
+
key=f"dl_{student_id or name}"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
requirements.txt
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---- Core UI ----
|
| 2 |
+
streamlit
|
| 3 |
+
|
| 4 |
+
# ---- Agent Orchestration / LLM Stack ----
|
| 5 |
+
langgraph
|
| 6 |
+
langchain
|
| 7 |
+
langchain-community
|
| 8 |
+
langchain-openai
|
| 9 |
+
openai
|
| 10 |
+
|
| 11 |
+
# ---- Database (TiDB via MySQL protocol) ----
|
| 12 |
+
SQLAlchemy
|
| 13 |
+
PyMySQL
|
| 14 |
+
cryptography
|
| 15 |
+
certifi # SSL CA bundle for TiDB
|
| 16 |
+
|
| 17 |
+
# ---- Serialization / Hashing (LangGraph deps) ----
|
| 18 |
+
ormsgpack
|
| 19 |
+
xxhash
|
| 20 |
+
|
| 21 |
+
# ---- Pydantic v2 line (required by langchain-openai/openai) ----
|
| 22 |
+
pydantic
|
| 23 |
+
pydantic-core
|
| 24 |
+
|
| 25 |
+
# ---- Utilities ----
|
| 26 |
+
tenacity
|
| 27 |
+
requests
|
| 28 |
+
markdownify
|
| 29 |
+
|
| 30 |
+
# ---- Search (optional tool for your agent) ----
|
| 31 |
+
duckduckgo-search
|
| 32 |
+
bcrypt
|
| 33 |
+
plotly
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
python-dotenv
|
| 37 |
+
requests
|
| 38 |
+
huggingface_hub==0.34.4
|
| 39 |
+
|
| 40 |
+
# frontend requirements.txt (add this line)
|
| 41 |
+
mysql-connector-python
|
tools/__init__.py
ADDED
|
File without changes
|
utils/api.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# utils/api.py
|
| 2 |
+
import os, json, requests
|
| 3 |
+
from urllib3.util.retry import Retry
|
| 4 |
+
from requests.adapters import HTTPAdapter
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# ---- Setup ----
|
| 8 |
+
BACKEND = (os.getenv("BACKEND_URL") or "").strip().rstrip("/")
|
| 9 |
+
if not BACKEND:
|
| 10 |
+
# Fail fast at import; Streamlit will surface this in the sidebar on first run
|
| 11 |
+
raise RuntimeError("BACKEND_URL is not set in Space secrets.")
|
| 12 |
+
|
| 13 |
+
# Accept either BACKEND_TOKEN or HF_TOKEN
|
| 14 |
+
TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
|
| 15 |
+
|
| 16 |
+
DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "30"))
|
| 17 |
+
|
| 18 |
+
_session = requests.Session()
|
| 19 |
+
|
| 20 |
+
# Light retry for transient network/server blips
|
| 21 |
+
retry = Retry(
|
| 22 |
+
total=3,
|
| 23 |
+
connect=3,
|
| 24 |
+
read=3,
|
| 25 |
+
backoff_factor=0.5,
|
| 26 |
+
status_forcelist=(429, 500, 502, 503, 504),
|
| 27 |
+
allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
| 28 |
+
)
|
| 29 |
+
_session.mount("https://", HTTPAdapter(max_retries=retry))
|
| 30 |
+
_session.mount("http://", HTTPAdapter(max_retries=retry))
|
| 31 |
+
|
| 32 |
+
# Default headers
|
| 33 |
+
_session.headers.update({
|
| 34 |
+
"Accept": "application/json, */*;q=0.1",
|
| 35 |
+
"User-Agent": "FinEdu-Frontend/1.0 (+spaces)",
|
| 36 |
+
})
|
| 37 |
+
if TOKEN:
|
| 38 |
+
_session.headers["Authorization"] = f"Bearer {TOKEN}"
|
| 39 |
+
|
| 40 |
+
def _json_or_raise(resp: requests.Response):
|
| 41 |
+
ctype = resp.headers.get("content-type", "")
|
| 42 |
+
if "application/json" in ctype:
|
| 43 |
+
return resp.json()
|
| 44 |
+
# Try to parse anyway; show a helpful error if not JSON
|
| 45 |
+
try:
|
| 46 |
+
return resp.json()
|
| 47 |
+
except Exception:
|
| 48 |
+
snippet = (resp.text or "")[:300]
|
| 49 |
+
raise RuntimeError(f"Expected JSON but got {ctype or 'unknown'}:\n{snippet}")
|
| 50 |
+
|
| 51 |
+
def _req(method: str, path: str, **kw):
|
| 52 |
+
if not path.startswith("/"):
|
| 53 |
+
path = "/" + path
|
| 54 |
+
url = f"{BACKEND}{path}"
|
| 55 |
+
kw.setdefault("timeout", DEFAULT_TIMEOUT)
|
| 56 |
+
try:
|
| 57 |
+
r = _session.request(method, url, **kw)
|
| 58 |
+
r.raise_for_status()
|
| 59 |
+
except requests.HTTPError as e:
|
| 60 |
+
body = ""
|
| 61 |
+
try:
|
| 62 |
+
body = r.text[:500]
|
| 63 |
+
except Exception:
|
| 64 |
+
pass
|
| 65 |
+
status = getattr(r, "status_code", "?")
|
| 66 |
+
# Give nicer hints for common auth misconfigs
|
| 67 |
+
if status in (401, 403):
|
| 68 |
+
raise RuntimeError(
|
| 69 |
+
f"{method} {path} failed [{status}] – auth rejected. "
|
| 70 |
+
f"Check BACKEND_TOKEN/HF_TOKEN permissions and that the backend Space is private/readable."
|
| 71 |
+
) from e
|
| 72 |
+
raise RuntimeError(f"{method} {path} failed [{status}]: {body}") from e
|
| 73 |
+
except requests.RequestException as e:
|
| 74 |
+
raise RuntimeError(f"{method} {path} failed: {e.__class__.__name__}: {e}") from e
|
| 75 |
+
return r
|
| 76 |
+
|
| 77 |
+
# ---- Health ----
|
| 78 |
+
def health():
|
| 79 |
+
# Prefer /health but allow root fallback if you change the backend later
|
| 80 |
+
try:
|
| 81 |
+
return _json_or_raise(_req("GET", "/health"))
|
| 82 |
+
except Exception:
|
| 83 |
+
# best-effort fallback
|
| 84 |
+
try:
|
| 85 |
+
_req("GET", "/")
|
| 86 |
+
return {"ok": True}
|
| 87 |
+
except Exception:
|
| 88 |
+
return {"ok": False}
|
| 89 |
+
|
| 90 |
+
#---helpers
|
| 91 |
+
|
| 92 |
+
# --- Optional API prefix (e.g., "/api" or "/v1")
|
| 93 |
+
API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/")
|
| 94 |
+
|
| 95 |
+
def _prefixes():
|
| 96 |
+
# Try configured prefix first, then common fallbacks
|
| 97 |
+
seen, out = set(), []
|
| 98 |
+
for p in [API_PREFIX_ENV, "", "/api", "/v1", "/api/v1"]:
|
| 99 |
+
p = (p or "").strip()
|
| 100 |
+
p = "" if p == "" else ("/" + p.strip("/"))
|
| 101 |
+
if p not in seen:
|
| 102 |
+
out.append(p)
|
| 103 |
+
seen.add(p)
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
def _try_candidates(method: str, candidates: list[tuple[str, dict]]):
|
| 107 |
+
"""
|
| 108 |
+
candidates: list of (path, request_kwargs) where path starts with "/" and
|
| 109 |
+
kwargs may include {'params':..., 'json':...}.
|
| 110 |
+
Tries multiple prefixes (e.g., "", "/api", "/v1") and returns JSON for first 2xx.
|
| 111 |
+
Auth errors (401/403) are raised immediately.
|
| 112 |
+
"""
|
| 113 |
+
tried = []
|
| 114 |
+
for pref in _prefixes():
|
| 115 |
+
for path, kw in candidates:
|
| 116 |
+
url = f"{BACKEND}{pref}{path}"
|
| 117 |
+
tried.append(f"{method} {url}")
|
| 118 |
+
try:
|
| 119 |
+
r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw)
|
| 120 |
+
except requests.RequestException as e:
|
| 121 |
+
# transient error: keep trying others
|
| 122 |
+
continue
|
| 123 |
+
if r.status_code in (401, 403):
|
| 124 |
+
snippet = (r.text or "")[:200]
|
| 125 |
+
raise RuntimeError(f"{method} {path} auth failed [{r.status_code}]: {snippet}")
|
| 126 |
+
if 200 <= r.status_code < 300:
|
| 127 |
+
return _json_or_raise(r)
|
| 128 |
+
# 404/405/etc.: try next candidate
|
| 129 |
+
raise RuntimeError("No matching endpoint for this operation. Tried:\n- " + "\n- ".join(tried))
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
#--helpers for student_db.py
|
| 133 |
+
def user_stats(student_id: int):
|
| 134 |
+
return _req("GET", f"/students/{student_id}/stats").json()
|
| 135 |
+
def list_assignments_for_student(student_id: int):
|
| 136 |
+
return _req("GET", f"/students/{student_id}/assignments").json()
|
| 137 |
+
def student_quiz_average(student_id: int):
|
| 138 |
+
d = _req("GET", f"/students/{student_id}/quiz_avg").json()
|
| 139 |
+
# Normalize common shapes: {"avg": 82}, {"score_pct": "82"}, "82", 82
|
| 140 |
+
if isinstance(d, dict):
|
| 141 |
+
for k in ("avg", "average", "score_pct", "score", "value"):
|
| 142 |
+
if k in d:
|
| 143 |
+
v = d[k]
|
| 144 |
+
break
|
| 145 |
+
else:
|
| 146 |
+
# fallback: first numeric-ish value
|
| 147 |
+
v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0)
|
| 148 |
+
else:
|
| 149 |
+
v = d
|
| 150 |
+
try:
|
| 151 |
+
# handle strings like "82" or "82%"
|
| 152 |
+
return int(round(float(str(v).strip().rstrip("%"))))
|
| 153 |
+
except Exception:
|
| 154 |
+
return 0
|
| 155 |
+
def recent_lessons_for_student(student_id: int, limit: int = 5):
|
| 156 |
+
return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json()
|
| 157 |
+
|
| 158 |
+
# # --- Teacher endpoints (backend Space) ---
|
| 159 |
+
# def create_class(teacher_id: int, name: str):
|
| 160 |
+
# return _json_or_raise(_req("POST", f"/teachers/{teacher_id}/classes",
|
| 161 |
+
# json={"name": name}))
|
| 162 |
+
|
| 163 |
+
# def teacher_tiles(teacher_id: int):
|
| 164 |
+
# return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
|
| 165 |
+
|
| 166 |
+
# def list_classes_by_teacher(teacher_id: int):
|
| 167 |
+
# return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/classes"))
|
| 168 |
+
|
| 169 |
+
# def class_student_metrics(class_id: int):
|
| 170 |
+
# return _json_or_raise(_req("GET", f"/classes/{class_id}/student_metrics"))
|
| 171 |
+
|
| 172 |
+
# def class_weekly_activity(class_id: int):
|
| 173 |
+
# return _json_or_raise(_req("GET", f"/classes/{class_id}/weekly_activity"))
|
| 174 |
+
|
| 175 |
+
# def class_progress_overview(class_id: int):
|
| 176 |
+
# return _json_or_raise(_req("GET", f"/classes/{class_id}/progress_overview"))
|
| 177 |
+
|
| 178 |
+
# def class_recent_activity(class_id: int, limit=6, days=30):
|
| 179 |
+
# return _json_or_raise(_req("GET", f"/classes/{class_id}/recent_activity",
|
| 180 |
+
# params={"limit": limit, "days": days}))
|
| 181 |
+
|
| 182 |
+
# def list_students_in_class(class_id: int):
|
| 183 |
+
# return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 184 |
+
|
| 185 |
+
# Optional if you want to compute levels server-side
|
| 186 |
+
def level_from_xp(xp: int):
|
| 187 |
+
return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"]
|
| 188 |
+
|
| 189 |
+
#--teacherlink.py helpers
|
| 190 |
+
def join_class_by_code(student_id: int, code: str):
|
| 191 |
+
d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
|
| 192 |
+
# backend may return {"class_id": ...} or full class object; both are fine
|
| 193 |
+
return d.get("class_id", d)
|
| 194 |
+
|
| 195 |
+
def list_classes_for_student(student_id: int):
|
| 196 |
+
return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
|
| 197 |
+
|
| 198 |
+
def class_content_counts(class_id: int):
|
| 199 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/counts"))
|
| 200 |
+
|
| 201 |
+
def student_class_progress(student_id: int, class_id: int):
|
| 202 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
|
| 203 |
+
|
| 204 |
+
def leave_class(student_id: int, class_id: int):
|
| 205 |
+
# could also be DELETE /classes/{class_id}/students/{student_id}
|
| 206 |
+
_json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
|
| 207 |
+
return True
|
| 208 |
+
|
| 209 |
+
def student_assignments_for_class(student_id: int, class_id: int):
|
| 210 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ---------- TEACHERS / CLASSES / CONTENT (BACKEND ROUTES THAT EXIST) ----------
|
| 216 |
+
|
| 217 |
+
# Classes
|
| 218 |
+
def create_class(teacher_id: int, name: str):
|
| 219 |
+
# Backend has POST /teachers/{teacher_id}/classes with body {name}
|
| 220 |
+
return _try_candidates("POST", [
|
| 221 |
+
(f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
|
| 222 |
+
# fallbacks if you ever rename:
|
| 223 |
+
("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 224 |
+
])
|
| 225 |
+
|
| 226 |
+
def list_classes_by_teacher(teacher_id: int):
|
| 227 |
+
return _try_candidates("GET", [
|
| 228 |
+
(f"/teachers/{teacher_id}/classes", {}),
|
| 229 |
+
])
|
| 230 |
+
|
| 231 |
+
def list_students_in_class(class_id: int):
|
| 232 |
+
# exact route in backend
|
| 233 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 234 |
+
|
| 235 |
+
def class_content_counts(class_id: int):
|
| 236 |
+
return _try_candidates("GET", [
|
| 237 |
+
(f"/classes/{class_id}/content_counts", {}),
|
| 238 |
+
(f"/classes/{class_id}/counts", {}),
|
| 239 |
+
])
|
| 240 |
+
|
| 241 |
+
def list_class_assignments(class_id: int):
|
| 242 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments"))
|
| 243 |
+
|
| 244 |
+
def class_analytics(class_id: int):
|
| 245 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics"))
|
| 246 |
+
|
| 247 |
+
def teacher_tiles(teacher_id: int):
|
| 248 |
+
return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
|
| 249 |
+
|
| 250 |
+
def class_student_metrics(class_id: int):
|
| 251 |
+
# backend: /classes/{id}/students/metrics
|
| 252 |
+
return _try_candidates("GET", [
|
| 253 |
+
(f"/classes/{class_id}/students/metrics", {}),
|
| 254 |
+
# tolerant fallbacks:
|
| 255 |
+
(f"/classes/{class_id}/student_metrics", {}),
|
| 256 |
+
(f"/classes/{class_id}/students", {}), # older shape (list of students)
|
| 257 |
+
])
|
| 258 |
+
|
| 259 |
+
def class_weekly_activity(class_id: int):
|
| 260 |
+
# backend: /classes/{id}/activity/weekly
|
| 261 |
+
return _try_candidates("GET", [
|
| 262 |
+
(f"/classes/{class_id}/activity/weekly", {}),
|
| 263 |
+
(f"/classes/{class_id}/weekly_activity", {}),
|
| 264 |
+
])
|
| 265 |
+
|
| 266 |
+
def class_progress_overview(class_id: int):
|
| 267 |
+
# backend: /classes/{id}/progress
|
| 268 |
+
return _try_candidates("GET", [
|
| 269 |
+
(f"/classes/{class_id}/progress", {}),
|
| 270 |
+
(f"/classes/{class_id}/progress_overview", {}),
|
| 271 |
+
])
|
| 272 |
+
|
| 273 |
+
def class_recent_activity(class_id: int, limit=6, days=30):
|
| 274 |
+
# backend: /classes/{id}/activity/recent
|
| 275 |
+
return _try_candidates("GET", [
|
| 276 |
+
(f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}),
|
| 277 |
+
(f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}),
|
| 278 |
+
])
|
| 279 |
+
|
| 280 |
+
# Lessons
|
| 281 |
+
def list_lessons_by_teacher(teacher_id: int):
|
| 282 |
+
return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons"))
|
| 283 |
+
|
| 284 |
+
def create_lesson(teacher_id: int, title: str, description: str,
|
| 285 |
+
subject: str, level: str, sections: list[dict]):
|
| 286 |
+
payload = {
|
| 287 |
+
"title": title,
|
| 288 |
+
"description": description,
|
| 289 |
+
"subject": subject,
|
| 290 |
+
"level": level,
|
| 291 |
+
"sections": sections,
|
| 292 |
+
}
|
| 293 |
+
# backend route:
|
| 294 |
+
d = _try_candidates("POST", [
|
| 295 |
+
(f"/teachers/{teacher_id}/lessons", {"json": payload}),
|
| 296 |
+
# fallback if you later add a flat /lessons route:
|
| 297 |
+
("/lessons", {"json": {"teacher_id": teacher_id, **payload}}),
|
| 298 |
+
])
|
| 299 |
+
# tolerate both {"lesson_id": N} or full object with id
|
| 300 |
+
return d.get("lesson_id", d.get("id", d))
|
| 301 |
+
|
| 302 |
+
def get_lesson(lesson_id: int):
|
| 303 |
+
return _json_or_raise(_req("GET", f"/lessons/{lesson_id}"))
|
| 304 |
+
|
| 305 |
+
def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str,
|
| 306 |
+
subject: str, level: str, sections: list[dict]):
|
| 307 |
+
d = _req("PUT", f"/lessons/{lesson_id}", json={
|
| 308 |
+
"teacher_id": teacher_id,
|
| 309 |
+
"title": title,
|
| 310 |
+
"description": description,
|
| 311 |
+
"subject": subject,
|
| 312 |
+
"level": level,
|
| 313 |
+
"sections": sections
|
| 314 |
+
}).json()
|
| 315 |
+
return bool(d.get("ok", True))
|
| 316 |
+
|
| 317 |
+
def delete_lesson(lesson_id: int, teacher_id: int):
|
| 318 |
+
d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
|
| 319 |
+
return bool(d.get("ok", True)), d.get("message", "")
|
| 320 |
+
|
| 321 |
+
# Quizzes
|
| 322 |
+
def list_quizzes_by_teacher(teacher_id: int):
|
| 323 |
+
return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes"))
|
| 324 |
+
|
| 325 |
+
def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
|
| 326 |
+
d = _req("POST", "/quizzes", json={
|
| 327 |
+
"lesson_id": lesson_id, "title": title, "items": items, "settings": settings
|
| 328 |
+
}).json()
|
| 329 |
+
return d.get("quiz_id", d.get("id", d))
|
| 330 |
+
|
| 331 |
+
# def get_quiz(quiz_id: int):
|
| 332 |
+
# return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
|
| 333 |
+
|
| 334 |
+
# def get_quiz(quiz_id: int):
|
| 335 |
+
# # NEW wrapper that hits GET /quizzes/{quiz_id}
|
| 336 |
+
# return _req("GET", f"/quizzes/{quiz_id}")
|
| 337 |
+
|
| 338 |
+
def get_quiz(quiz_id: int):
|
| 339 |
+
"""Fetch a teacher-created quiz (GET /quizzes/{quiz_id}) and return JSON."""
|
| 340 |
+
return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
|
| 341 |
+
|
| 342 |
+
def submit_quiz(student_id, assignment_id, quiz_id, score, total, details):
|
| 343 |
+
payload = {"student_id": student_id, "assignment_id": assignment_id,
|
| 344 |
+
"quiz_id": quiz_id, "score": score, "total": total, "details": details}
|
| 345 |
+
return _req("POST", "/quizzes/submit", json=payload)
|
| 346 |
+
|
| 347 |
+
def update_quiz(quiz_id: int,
|
| 348 |
+
teacher_id: int, title: str, items: list[dict], settings: dict):
|
| 349 |
+
d = _req("PUT", f"/quizzes/{quiz_id}", json={
|
| 350 |
+
"teacher_id": teacher_id, "title": title, "items": items, "settings": settings
|
| 351 |
+
}).json()
|
| 352 |
+
return bool(d.get("ok", True))
|
| 353 |
+
|
| 354 |
+
def delete_quiz(quiz_id: int, teacher_id: int):
|
| 355 |
+
d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
|
| 356 |
+
return bool(d.get("ok", True)), d.get("message", "")
|
| 357 |
+
|
| 358 |
+
def list_assigned_students_for_lesson(lesson_id: int):
|
| 359 |
+
return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees"))
|
| 360 |
+
|
| 361 |
+
def list_assigned_students_for_quiz(quiz_id: int):
|
| 362 |
+
return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}/assignees"))
|
| 363 |
+
|
| 364 |
+
# Assignments
|
| 365 |
+
def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int, due_at: str | None = None):
|
| 366 |
+
# backend route name is /assign (not /assignments)
|
| 367 |
+
d = _try_candidates("POST", [
|
| 368 |
+
("/assign", {"json": {
|
| 369 |
+
"lesson_id": lesson_id, "quiz_id": quiz_id,
|
| 370 |
+
"class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
|
| 371 |
+
}}),
|
| 372 |
+
("/assignments", {"json": {
|
| 373 |
+
"lesson_id": lesson_id, "quiz_id": quiz_id,
|
| 374 |
+
"class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
|
| 375 |
+
}}),
|
| 376 |
+
])
|
| 377 |
+
return bool(d.get("ok", True))
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
# # ---- Classes / Teacher endpoints (tolerant) ----
|
| 383 |
+
# def create_class(teacher_id: int, name: str):
|
| 384 |
+
# return _try_candidates("POST", [
|
| 385 |
+
# (f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
|
| 386 |
+
# (f"/teachers/{teacher_id}/classrooms",{"json": {"name": name}}),
|
| 387 |
+
# ("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 388 |
+
# ("/classrooms", {"json": {"teacher_id": teacher_id, "name": name}}),
|
| 389 |
+
# ])
|
| 390 |
+
|
| 391 |
+
# def list_classes_by_teacher(teacher_id: int):
|
| 392 |
+
# return _try_candidates("GET", [
|
| 393 |
+
# (f"/teachers/{teacher_id}/classes", {}),
|
| 394 |
+
# (f"/teachers/{teacher_id}/classrooms", {}),
|
| 395 |
+
# (f"/classes/by-teacher/{teacher_id}", {}),
|
| 396 |
+
# (f"/classrooms/by-teacher/{teacher_id}", {}),
|
| 397 |
+
# ("/classes", {"params": {"teacher_id": teacher_id}}),
|
| 398 |
+
# ("/classrooms", {"params": {"teacher_id": teacher_id}}),
|
| 399 |
+
# ])
|
| 400 |
+
|
| 401 |
+
# def list_students_in_class(class_id: int):
|
| 402 |
+
# return _try_candidates("GET", [
|
| 403 |
+
# (f"/classes/{class_id}/students", {}),
|
| 404 |
+
# (f"/classrooms/{class_id}/students", {}),
|
| 405 |
+
# ("/students", {"params": {"class_id": class_id}}),
|
| 406 |
+
# ])
|
| 407 |
+
|
| 408 |
+
# def class_content_counts(class_id: int):
|
| 409 |
+
# return _try_candidates("GET", [
|
| 410 |
+
# (f"/classes/{class_id}/content_counts", {}),
|
| 411 |
+
# (f"/classrooms/{class_id}/content_counts", {}),
|
| 412 |
+
# (f"/classes/{class_id}/counts", {}),
|
| 413 |
+
# (f"/classrooms/{class_id}/counts", {}),
|
| 414 |
+
# ])
|
| 415 |
+
|
| 416 |
+
# def list_class_assignments(class_id: int):
|
| 417 |
+
# return _try_candidates("GET", [
|
| 418 |
+
# (f"/classes/{class_id}/assignments", {}),
|
| 419 |
+
# (f"/classrooms/{class_id}/assignments", {}),
|
| 420 |
+
# ("/assignments", {"params": {"class_id": class_id}}),
|
| 421 |
+
# ])
|
| 422 |
+
|
| 423 |
+
# def class_analytics(class_id: int):
|
| 424 |
+
# return _try_candidates("GET", [
|
| 425 |
+
# (f"/classes/{class_id}/analytics", {}),
|
| 426 |
+
# (f"/classrooms/{class_id}/analytics", {}),
|
| 427 |
+
# ])
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
# #--contentmanage.py helpers
|
| 431 |
+
|
| 432 |
+
# # ---------- Teacher/content management endpoints (backend Space) ----------
|
| 433 |
+
# def list_classes_by_teacher(teacher_id: int):
|
| 434 |
+
# return _req("GET", f"/teachers/{teacher_id}/classes").json()
|
| 435 |
+
|
| 436 |
+
# def list_all_students_for_teacher(teacher_id: int):
|
| 437 |
+
# return _req("GET", f"/teachers/{teacher_id}/students").json()
|
| 438 |
+
|
| 439 |
+
# def list_lessons_by_teacher(teacher_id: int):
|
| 440 |
+
# return _req("GET", f"/teachers/{teacher_id}/lessons").json()
|
| 441 |
+
|
| 442 |
+
# def list_quizzes_by_teacher(teacher_id: int):
|
| 443 |
+
# return _req("GET", f"/teachers/{teacher_id}/quizzes").json()
|
| 444 |
+
|
| 445 |
+
# def create_lesson(teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
|
| 446 |
+
# d = _req("POST", "/lessons", json={
|
| 447 |
+
# "teacher_id": teacher_id, "title": title, "description": description,
|
| 448 |
+
# "subject": subject, "level": level, "sections": sections
|
| 449 |
+
# }).json()
|
| 450 |
+
# return d["lesson_id"]
|
| 451 |
+
|
| 452 |
+
# def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
|
| 453 |
+
# d = _req("PUT", f"/lessons/{lesson_id}", json={
|
| 454 |
+
# "teacher_id": teacher_id, "title": title, "description": description,
|
| 455 |
+
# "subject": subject, "level": level, "sections": sections
|
| 456 |
+
# }).json()
|
| 457 |
+
# return bool(d.get("ok", True))
|
| 458 |
+
|
| 459 |
+
# def delete_lesson(lesson_id: int, teacher_id: int):
|
| 460 |
+
# d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
|
| 461 |
+
# return bool(d.get("ok", True)), d.get("message", "")
|
| 462 |
+
|
| 463 |
+
# def get_lesson(lesson_id: int):
|
| 464 |
+
# return _req("GET", f"/lessons/{lesson_id}").json() # {"lesson":{...}, "sections":[...]}
|
| 465 |
+
|
| 466 |
+
# def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
|
| 467 |
+
# d = _req("POST", "/quizzes", json={"lesson_id": lesson_id, "title": title, "items": items, "settings": settings}).json()
|
| 468 |
+
# return d["quiz_id"]
|
| 469 |
+
|
| 470 |
+
# def update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict):
|
| 471 |
+
# d = _req("PUT", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id, "title": title, "items": items, "settings": settings}).json()
|
| 472 |
+
# return bool(d.get("ok", True))
|
| 473 |
+
|
| 474 |
+
# def delete_quiz(quiz_id: int, teacher_id: int):
|
| 475 |
+
# d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
|
| 476 |
+
# return bool(d.get("ok", True)), d.get("message", "")
|
| 477 |
+
|
| 478 |
+
# def list_assigned_students_for_lesson(lesson_id: int):
|
| 479 |
+
# return _req("GET", f"/lessons/{lesson_id}/assignees").json()
|
| 480 |
+
|
| 481 |
+
# def list_assigned_students_for_quiz(quiz_id: int):
|
| 482 |
+
# return _req("GET", f"/quizzes/{quiz_id}/assignees").json()
|
| 483 |
+
|
| 484 |
+
# def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int):
|
| 485 |
+
# d = _req("POST", "/assignments", json={
|
| 486 |
+
# "lesson_id": lesson_id, "quiz_id": quiz_id, "class_id": class_id, "teacher_id": teacher_id
|
| 487 |
+
# }).json()
|
| 488 |
+
# return bool(d.get("ok", True))
|
| 489 |
+
|
| 490 |
+
# #-- studentlist helpers
|
| 491 |
+
|
| 492 |
+
# def list_classes_by_teacher(teacher_id: int):
|
| 493 |
+
# return _req("GET", f"/teachers/{teacher_id}/classes").json()
|
| 494 |
+
|
| 495 |
+
# def get_class(class_id: int):
|
| 496 |
+
# return _req("GET", f"/classes/{class_id}").json()
|
| 497 |
+
|
| 498 |
+
# def class_student_metrics(class_id: int):
|
| 499 |
+
# # expected to return list of rows with fields used in the UI
|
| 500 |
+
# return _req("GET", f"/classes/{class_id}/students").json()
|
| 501 |
+
|
| 502 |
+
# def list_assignments_for_student(student_id: int):
|
| 503 |
+
# return _req("GET", f"/students/{student_id}/assignments").json()
|
| 504 |
+
|
| 505 |
+
# ---------- LLM-based quiz generation (backend uses GEN_MODEL) ----------
|
| 506 |
+
def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
|
| 507 |
+
"""
|
| 508 |
+
Backend should read GEN_MODEL from env and use your chosen model (llama-3.1-8b-instruct).
|
| 509 |
+
Return shape: {"items":[{"question":...,"options":[...],"answer_key":"A"}]}
|
| 510 |
+
"""
|
| 511 |
+
return _req("POST", "/quiz/generate", json={
|
| 512 |
+
"content": content, "n_questions": n_questions, "subject": subject, "level": level
|
| 513 |
+
}).json()
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
# ---- Legacy agent endpoints (keep) ----
|
| 517 |
+
def start_agent(student_id: int, lesson_id: int, level_slug: str):
|
| 518 |
+
return _json_or_raise(_req("POST", "/agent/start",
|
| 519 |
+
json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
|
| 520 |
+
|
| 521 |
+
def get_agent_quiz(student_id: int, lesson_id: int, level_slug: str):
|
| 522 |
+
d = _json_or_raise(_req("POST", "/agent/quiz",
|
| 523 |
+
json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
|
| 524 |
+
return d["items"]
|
| 525 |
+
|
| 526 |
+
def grade_quiz(student_id: int, lesson_id: int, level_slug: str,
|
| 527 |
+
answers: list[str], assignment_id: int | None = None):
|
| 528 |
+
d = _json_or_raise(_req("POST", "/agent/grade",
|
| 529 |
+
json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
|
| 530 |
+
"answers": answers, "assignment_id": assignment_id}))
|
| 531 |
+
return d["score"], d["total"]
|
| 532 |
+
|
| 533 |
+
def next_step(student_id: int, lesson_id: int, level_slug: str,
|
| 534 |
+
answers: list[str], assignment_id: int | None = None):
|
| 535 |
+
return _json_or_raise(_req("POST", "/agent/coach_or_celebrate",
|
| 536 |
+
json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
|
| 537 |
+
"answers": answers, "assignment_id": assignment_id}))
|
| 538 |
+
|
| 539 |
+
# ---- Auth ----
|
| 540 |
+
def login(email: str, password: str):
|
| 541 |
+
return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password}))
|
| 542 |
+
|
| 543 |
+
def signup_student(name: str, email: str, password: str, level_label: str, country_label: str):
|
| 544 |
+
payload_student = {
|
| 545 |
+
"name": name, "email": email, "password": password,
|
| 546 |
+
"level_label": level_label, "country_label": country_label
|
| 547 |
+
}
|
| 548 |
+
# Prefer dedicated route; fall back to /auth/register with role
|
| 549 |
+
return _try_candidates("POST", [
|
| 550 |
+
("/auth/signup/student", {"json": payload_student}),
|
| 551 |
+
("/auth/register", {"json": {
|
| 552 |
+
"role": "student", "name": name, "email": email, "password": password,
|
| 553 |
+
"level": level_label, "country": country_label
|
| 554 |
+
}}),
|
| 555 |
+
])
|
| 556 |
+
|
| 557 |
+
def signup_teacher(title: str, name: str, email: str, password: str):
|
| 558 |
+
payload_teacher = {"title": title, "name": name, "email": email, "password": password}
|
| 559 |
+
return _try_candidates("POST", [
|
| 560 |
+
("/auth/signup/teacher", {"json": payload_teacher}),
|
| 561 |
+
("/auth/register", {"json": {
|
| 562 |
+
"role": "teacher", "title": title, "name": name, "email": email, "password": password
|
| 563 |
+
}}),
|
| 564 |
+
])
|
| 565 |
+
|
| 566 |
+
# ---- New LangGraph-backed endpoints ----
|
| 567 |
+
def fetch_lesson_content(lesson: str, module: str, topic: str):
|
| 568 |
+
r = _json_or_raise(_req("POST", "/lesson",
|
| 569 |
+
json={"lesson": lesson, "module": module, "topic": topic}))
|
| 570 |
+
return r["lesson_content"]
|
| 571 |
+
|
| 572 |
+
def submit_lesson_quiz(lesson: str, module: str, topic: str, responses: dict):
|
| 573 |
+
return _json_or_raise(_req("POST", "/lesson-quiz",
|
| 574 |
+
json={"lesson": lesson, "module": module, "topic": topic, "responses": responses}))
|
| 575 |
+
|
| 576 |
+
def submit_practice_quiz(lesson: str, responses: dict):
|
| 577 |
+
return _json_or_raise(_req("POST", "/practice-quiz",
|
| 578 |
+
json={"lesson": lesson, "responses": responses}))
|
| 579 |
+
|
| 580 |
+
def send_to_chatbot(messages: list[dict]):
|
| 581 |
+
return _json_or_raise(_req("POST", "/chatbot", json={"messages": messages}))
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
# --- Game API helpers ---
|
| 585 |
+
|
| 586 |
+
def record_money_match_play(user_id: int, target: int, total: int,
|
| 587 |
+
elapsed_ms: int, matched: bool, gained_xp: int):
|
| 588 |
+
payload = {
|
| 589 |
+
"user_id": user_id, "target": target, "total": total,
|
| 590 |
+
"elapsed_ms": elapsed_ms, "matched": matched, "gained_xp": gained_xp,
|
| 591 |
+
}
|
| 592 |
+
return _try_candidates("POST", [
|
| 593 |
+
("/games/money_match/record", {"json": payload}),
|
| 594 |
+
])
|
| 595 |
+
|
| 596 |
+
def record_budget_builder_play(user_id: int, weekly_allowance: int, budget_score: int,
|
| 597 |
+
elapsed_ms: int, allocations: list[dict], gained_xp: int | None):
|
| 598 |
+
payload = {
|
| 599 |
+
"user_id": user_id,
|
| 600 |
+
"weekly_allowance": weekly_allowance,
|
| 601 |
+
"budget_score": budget_score,
|
| 602 |
+
"elapsed_ms": elapsed_ms,
|
| 603 |
+
"allocations": allocations,
|
| 604 |
+
"gained_xp": gained_xp,
|
| 605 |
+
}
|
| 606 |
+
return _try_candidates("POST", [
|
| 607 |
+
("/games/budget_builder/record", {"json": payload}),
|
| 608 |
+
])
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
def record_debt_dilemma_play(user_id: int, loans_cleared: int,
|
| 612 |
+
mistakes: int, elapsed_ms: int, gained_xp: int):
|
| 613 |
+
payload = {
|
| 614 |
+
"user_id": user_id,
|
| 615 |
+
"loans_cleared": loans_cleared,
|
| 616 |
+
"mistakes": mistakes,
|
| 617 |
+
"elapsed_ms": elapsed_ms,
|
| 618 |
+
"gained_xp": gained_xp,
|
| 619 |
+
}
|
| 620 |
+
return _try_candidates("POST", [
|
| 621 |
+
("/games/debt_dilemma/record", {"json": payload}),
|
| 622 |
+
("/api/games/debt_dilemma/record", {"json": payload}),
|
| 623 |
+
("/api/v1/games/debt_dilemma/record", {"json": payload}),
|
| 624 |
+
])
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def record_profit_puzzler_play(user_id: int, puzzles_solved: int, mistakes: int, elapsed_ms: int, gained_xp: int | None = None):
|
| 628 |
+
payload = {"user_id": user_id, "puzzles_solved": puzzles_solved, "mistakes": mistakes, "elapsed_ms": elapsed_ms}
|
| 629 |
+
if gained_xp is not None:
|
| 630 |
+
payload["gained_xp"] = gained_xp
|
| 631 |
+
return _try_candidates("POST", [("/games/profit_puzzler/record", {"json": payload})])
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
def generate_quiz(lesson_id: int, level_slug: str, lesson_title: str):
|
| 635 |
+
r = requests.post(f"{BACKEND}/generate_quiz", json={
|
| 636 |
+
"lesson_id": lesson_id,
|
| 637 |
+
"level_slug": level_slug,
|
| 638 |
+
"lesson_title": lesson_title
|
| 639 |
+
}, timeout=60)
|
| 640 |
+
r.raise_for_status()
|
| 641 |
+
return r.json()["quiz"]
|
| 642 |
+
|
| 643 |
+
def submit_quiz(lesson_id: int, level_slug: str, user_answers: list[dict], original_quiz: list[dict]):
|
| 644 |
+
r = requests.post(f"{BACKEND}/submit_quiz", json={
|
| 645 |
+
"lesson_id": lesson_id,
|
| 646 |
+
"level_slug": level_slug,
|
| 647 |
+
"user_answers": user_answers,
|
| 648 |
+
"original_quiz": original_quiz
|
| 649 |
+
}, timeout=90)
|
| 650 |
+
r.raise_for_status()
|
| 651 |
+
return r.json()
|
| 652 |
+
|
| 653 |
+
def tutor_explain(lesson_id: int, level_slug: str, wrong: list[dict]):
|
| 654 |
+
r = requests.post(f"{BACKEND}/tutor/explain", json={
|
| 655 |
+
"lesson_id": lesson_id,
|
| 656 |
+
"level_slug": level_slug,
|
| 657 |
+
"wrong": wrong
|
| 658 |
+
}, timeout=60)
|
| 659 |
+
r.raise_for_status()
|
| 660 |
+
return r.json()["feedback"]
|
utils/db.py
ADDED
|
@@ -0,0 +1,1327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# utils/db.py (top of file)
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import certifi
|
| 5 |
+
from contextlib import contextmanager
|
| 6 |
+
from datetime import date, timedelta
|
| 7 |
+
|
| 8 |
+
# password hashing
|
| 9 |
+
import bcrypt
|
| 10 |
+
|
| 11 |
+
# --- Feature flag: DB off by default in frontend Space ---
|
| 12 |
+
DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
|
| 13 |
+
|
| 14 |
+
# Import mysql connector only when DB is enabled
|
| 15 |
+
MYSQL_AVAILABLE = False
|
| 16 |
+
if not DISABLE_DB:
|
| 17 |
+
try:
|
| 18 |
+
import mysql.connector # provided by mysql-connector-python
|
| 19 |
+
from mysql.connector import Error # noqa: F401
|
| 20 |
+
MYSQL_AVAILABLE = True
|
| 21 |
+
except Exception:
|
| 22 |
+
MYSQL_AVAILABLE = False # will raise a friendly error if used
|
| 23 |
+
|
| 24 |
+
def _db_disabled_error():
|
| 25 |
+
raise RuntimeError(
|
| 26 |
+
"Database access is disabled in this frontend (DISABLE_DB=1). "
|
| 27 |
+
"Route calls through your backend Space instead."
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
def get_db_connection():
|
| 31 |
+
if DISABLE_DB or not MYSQL_AVAILABLE:
|
| 32 |
+
_db_disabled_error()
|
| 33 |
+
ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
|
| 34 |
+
ssl_ca = certifi.where() if ssl_enabled else None
|
| 35 |
+
return mysql.connector.connect(
|
| 36 |
+
host=os.getenv("TIDB_HOST"),
|
| 37 |
+
port=int(os.getenv("TIDB_PORT", 4000)),
|
| 38 |
+
user=os.getenv("TIDB_USER"),
|
| 39 |
+
password=os.getenv("TIDB_PASSWORD"),
|
| 40 |
+
database=os.getenv("TIDB_DATABASE", "fin_ed_agentic"),
|
| 41 |
+
ssl_ca=ssl_ca,
|
| 42 |
+
ssl_verify_cert=ssl_enabled,
|
| 43 |
+
autocommit=True,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
@contextmanager
|
| 47 |
+
def cursor(dict_rows=True):
|
| 48 |
+
if DISABLE_DB or not MYSQL_AVAILABLE:
|
| 49 |
+
_db_disabled_error()
|
| 50 |
+
conn = get_db_connection()
|
| 51 |
+
try:
|
| 52 |
+
cur = conn.cursor(dictionary=dict_rows)
|
| 53 |
+
yield cur
|
| 54 |
+
conn.commit()
|
| 55 |
+
finally:
|
| 56 |
+
cur.close()
|
| 57 |
+
conn.close()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# password hashing
|
| 62 |
+
import bcrypt
|
| 63 |
+
|
| 64 |
+
# ----------- label <-> slug mappers for UI selects -----------
|
| 65 |
+
COUNTRY_SLUG = {
|
| 66 |
+
"Jamaica": "jamaica", "USA": "usa", "UK": "uk",
|
| 67 |
+
"India": "india", "Canada": "canada", "Other": "other", "N/A": "na"
|
| 68 |
+
}
|
| 69 |
+
LEVEL_SLUG = {
|
| 70 |
+
"Beginner": "beginner", "Intermediate": "intermediate", "Advanced": "advanced", "N/A": "na"
|
| 71 |
+
}
|
| 72 |
+
ROLE_SLUG = {"Student": "student", "Teacher": "teacher"}
|
| 73 |
+
|
| 74 |
+
def _slug(s: str) -> str:
|
| 75 |
+
return (s or "").strip().lower()
|
| 76 |
+
|
| 77 |
+
def hash_password(plain: str) -> bytes:
|
| 78 |
+
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt())
|
| 79 |
+
|
| 80 |
+
def verify_password(plain: str, hashed: bytes | None) -> bool:
|
| 81 |
+
if not plain or not hashed:
|
| 82 |
+
return False
|
| 83 |
+
try:
|
| 84 |
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed)
|
| 85 |
+
except Exception:
|
| 86 |
+
return False
|
| 87 |
+
|
| 88 |
+
def _ensure_na_slugs():
|
| 89 |
+
"""
|
| 90 |
+
Make sure 'na' exists in countries/levels for teacher rows.
|
| 91 |
+
Harmless if already present.
|
| 92 |
+
"""
|
| 93 |
+
with cursor() as cur:
|
| 94 |
+
cur.execute("INSERT IGNORE INTO countries(slug,label) VALUES('na','N/A')")
|
| 95 |
+
cur.execute("INSERT IGNORE INTO levels(slug,label) VALUES('na','N/A')")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# def get_db_connection():
|
| 99 |
+
# ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
|
| 100 |
+
# ssl_ca = certifi.where() if ssl_enabled else None
|
| 101 |
+
# return mysql.connector.connect(
|
| 102 |
+
# host=os.getenv("TIDB_HOST"),
|
| 103 |
+
# port=int(os.getenv("TIDB_PORT", 4000)),
|
| 104 |
+
# user=os.getenv("TIDB_USER"),
|
| 105 |
+
# password=os.getenv("TIDB_PASSWORD"),
|
| 106 |
+
# database=os.getenv("TIDB_DATABASE", "agenticfinance"),
|
| 107 |
+
# ssl_ca=ssl_ca,
|
| 108 |
+
# ssl_verify_cert=ssl_enabled,
|
| 109 |
+
# autocommit=True,
|
| 110 |
+
# )
|
| 111 |
+
|
| 112 |
+
@contextmanager
|
| 113 |
+
def cursor(dict_rows=True):
|
| 114 |
+
conn = get_db_connection()
|
| 115 |
+
try:
|
| 116 |
+
cur = conn.cursor(dictionary=dict_rows)
|
| 117 |
+
yield cur
|
| 118 |
+
conn.commit()
|
| 119 |
+
finally:
|
| 120 |
+
cur.close()
|
| 121 |
+
conn.close()
|
| 122 |
+
|
| 123 |
+
# ---------- USERS ----------
|
| 124 |
+
def create_user(name:str, email:str, country:str, level:str, role:str):
|
| 125 |
+
|
| 126 |
+
slug = lambda s: s.strip().lower()
|
| 127 |
+
with cursor() as cur:
|
| 128 |
+
cur.execute("""
|
| 129 |
+
INSERT INTO users(name,email,country_slug,level_slug,role_slug)
|
| 130 |
+
VALUES (%s,%s,%s,%s,%s)
|
| 131 |
+
""", (name, email.strip().lower(), slug(country), slug(level), slug(role)))
|
| 132 |
+
return True
|
| 133 |
+
|
| 134 |
+
# role-specific creators
|
| 135 |
+
def create_student(*, name:str, email:str, password:str, level_label:str, country_label:str) -> bool:
|
| 136 |
+
"""
|
| 137 |
+
level_label/country_label are UI labels (e.g., 'Beginner', 'Jamaica').
|
| 138 |
+
"""
|
| 139 |
+
level_slug = LEVEL_SLUG.get(level_label, _slug(level_label))
|
| 140 |
+
country_slug = COUNTRY_SLUG.get(country_label, _slug(country_label))
|
| 141 |
+
with cursor() as cur:
|
| 142 |
+
cur.execute("""
|
| 143 |
+
INSERT INTO users (name,email,password_hash,title,country_slug,level_slug,role_slug)
|
| 144 |
+
VALUES (%s,%s,%s,NULL,%s,%s,'student')
|
| 145 |
+
""", (name.strip(), email.strip().lower(), hash_password(password), country_slug, level_slug))
|
| 146 |
+
return True
|
| 147 |
+
|
| 148 |
+
def create_teacher(*, title:str, name:str, email:str, password:str) -> bool:
|
| 149 |
+
"""
|
| 150 |
+
Teachers do not provide level/country; we store 'na' for both.
|
| 151 |
+
"""
|
| 152 |
+
_ensure_na_slugs()
|
| 153 |
+
with cursor() as cur:
|
| 154 |
+
cur.execute("""
|
| 155 |
+
INSERT INTO users (title,name,email,password_hash,country_slug,level_slug,role_slug)
|
| 156 |
+
VALUES (%s,%s,%s,%s,'na','na','teacher')
|
| 157 |
+
""", (title.strip(), name.strip(), email.strip().lower(), hash_password(password)))
|
| 158 |
+
return True
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def get_user_by_email(email:str):
|
| 162 |
+
with cursor() as cur:
|
| 163 |
+
cur.execute("""
|
| 164 |
+
SELECT
|
| 165 |
+
u.user_id, u.title, u.name, u.email, u.password_hash,
|
| 166 |
+
u.country_slug, c.label AS country,
|
| 167 |
+
u.level_slug, l.label AS level,
|
| 168 |
+
u.role_slug, r.label AS role
|
| 169 |
+
FROM users u
|
| 170 |
+
JOIN countries c ON c.slug = u.country_slug
|
| 171 |
+
JOIN levels l ON l.slug = u.level_slug
|
| 172 |
+
JOIN roles r ON r.slug = u.role_slug
|
| 173 |
+
WHERE u.email=%s
|
| 174 |
+
LIMIT 1
|
| 175 |
+
""", (email.strip().lower(),))
|
| 176 |
+
u = cur.fetchone()
|
| 177 |
+
if not u:
|
| 178 |
+
return None
|
| 179 |
+
|
| 180 |
+
u["role"] = "Teacher" if u["role_slug"] == "teacher" else "Student"
|
| 181 |
+
return u
|
| 182 |
+
|
| 183 |
+
def check_password(email: str, plain_password: str) -> dict | None:
|
| 184 |
+
"""
|
| 185 |
+
Returns the user dict if password is correct, else None.
|
| 186 |
+
"""
|
| 187 |
+
user = get_user_by_email(email)
|
| 188 |
+
if not user:
|
| 189 |
+
return None
|
| 190 |
+
if verify_password(plain_password, user.get("password_hash")):
|
| 191 |
+
return user
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
# ---------- CLASSES ----------
|
| 196 |
+
import random, string
|
| 197 |
+
def _code():
|
| 198 |
+
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
| 199 |
+
|
| 200 |
+
def create_class(teacher_id:int, name:str):
|
| 201 |
+
# ensure unique code
|
| 202 |
+
for _ in range(20):
|
| 203 |
+
code = _code()
|
| 204 |
+
with cursor() as cur:
|
| 205 |
+
cur.execute("SELECT 1 FROM classes WHERE code=%s", (code,))
|
| 206 |
+
if not cur.fetchone():
|
| 207 |
+
cur.execute("INSERT INTO classes(teacher_id,name,code) VALUES(%s,%s,%s)",
|
| 208 |
+
(teacher_id, name, code))
|
| 209 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 210 |
+
cid = cur.fetchone()["id"]
|
| 211 |
+
return {"class_id": cid, "code": code}
|
| 212 |
+
raise RuntimeError("Could not generate unique class code")
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def join_class_by_code(student_id:int, code:str):
|
| 217 |
+
with cursor() as cur:
|
| 218 |
+
cur.execute("SELECT class_id FROM classes WHERE code=%s", (code.strip().upper(),))
|
| 219 |
+
row = cur.fetchone()
|
| 220 |
+
if not row:
|
| 221 |
+
raise ValueError("Invalid class code")
|
| 222 |
+
cur.execute("INSERT IGNORE INTO class_students(class_id,student_id) VALUES(%s,%s)",
|
| 223 |
+
(row["class_id"], student_id))
|
| 224 |
+
return row["class_id"]
|
| 225 |
+
|
| 226 |
+
def list_students_in_class(class_id:int):
|
| 227 |
+
with cursor() as cur:
|
| 228 |
+
cur.execute("""
|
| 229 |
+
SELECT
|
| 230 |
+
u.user_id, u.name, u.email, u.level_slug,
|
| 231 |
+
cs.joined_at, -- <- show true join date
|
| 232 |
+
u.created_at
|
| 233 |
+
FROM class_students cs
|
| 234 |
+
JOIN users u ON u.user_id = cs.student_id
|
| 235 |
+
WHERE cs.class_id = %s
|
| 236 |
+
ORDER BY u.name
|
| 237 |
+
""", (class_id,))
|
| 238 |
+
return cur.fetchall()
|
| 239 |
+
|
| 240 |
+
def class_analytics(class_id:int):
|
| 241 |
+
"""
|
| 242 |
+
Returns:
|
| 243 |
+
class_avg -> 0..1 average quiz score for the class (from v_class_stats)
|
| 244 |
+
total_xp -> sum of xp_log.delta for students in this class
|
| 245 |
+
lessons_completed -> count of completed lesson_progress entries for lessons assigned to this class
|
| 246 |
+
"""
|
| 247 |
+
out = {"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0}
|
| 248 |
+
|
| 249 |
+
with cursor() as cur:
|
| 250 |
+
# class average from view
|
| 251 |
+
cur.execute("SELECT class_avg FROM v_class_stats WHERE class_id=%s", (class_id,))
|
| 252 |
+
row = cur.fetchone()
|
| 253 |
+
if row:
|
| 254 |
+
out["class_avg"] = float(row["class_avg"] or 0)
|
| 255 |
+
|
| 256 |
+
# total XP for all students in this class
|
| 257 |
+
cur.execute("""
|
| 258 |
+
SELECT COALESCE(SUM(x.delta),0) AS total_xp
|
| 259 |
+
FROM xp_log x
|
| 260 |
+
JOIN class_students cs ON cs.student_id = x.user_id
|
| 261 |
+
WHERE cs.class_id = %s
|
| 262 |
+
""", (class_id,))
|
| 263 |
+
out["total_xp"] = int((cur.fetchone() or {"total_xp": 0})["total_xp"])
|
| 264 |
+
|
| 265 |
+
# lessons completed that were actually assigned to this class
|
| 266 |
+
cur.execute("""
|
| 267 |
+
SELECT COUNT(*) AS n
|
| 268 |
+
FROM lesson_progress lp
|
| 269 |
+
JOIN class_students cs ON cs.student_id = lp.user_id
|
| 270 |
+
JOIN assignments a ON a.lesson_id = lp.lesson_id
|
| 271 |
+
WHERE cs.class_id = %s
|
| 272 |
+
AND a.class_id = %s
|
| 273 |
+
AND lp.status = 'completed'
|
| 274 |
+
""", (class_id, class_id))
|
| 275 |
+
out["lessons_completed"] = int((cur.fetchone() or {"n": 0})["n"])
|
| 276 |
+
|
| 277 |
+
return out
|
| 278 |
+
|
| 279 |
+
# ---------- Teacher dash for real time data - Class Helpers ----------
|
| 280 |
+
def class_content_counts(class_id:int):
|
| 281 |
+
# counts of distinct lessons and quizzes assigned to this class
|
| 282 |
+
with cursor() as cur:
|
| 283 |
+
cur.execute("""
|
| 284 |
+
SELECT
|
| 285 |
+
COUNT(DISTINCT lesson_id) AS lessons,
|
| 286 |
+
COUNT(DISTINCT quiz_id) AS quizzes
|
| 287 |
+
FROM assignments
|
| 288 |
+
WHERE class_id=%s
|
| 289 |
+
""", (class_id,))
|
| 290 |
+
row = cur.fetchone() or {"lessons": 0, "quizzes": 0}
|
| 291 |
+
return row
|
| 292 |
+
|
| 293 |
+
def list_class_assignments(class_id:int):
|
| 294 |
+
with cursor() as cur:
|
| 295 |
+
cur.execute("""
|
| 296 |
+
SELECT
|
| 297 |
+
a.assignment_id,
|
| 298 |
+
a.created_at,
|
| 299 |
+
l.lesson_id, l.title, l.subject, l.level,
|
| 300 |
+
a.quiz_id
|
| 301 |
+
FROM assignments a
|
| 302 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 303 |
+
WHERE a.class_id=%s
|
| 304 |
+
ORDER BY a.created_at DESC
|
| 305 |
+
""", (class_id,))
|
| 306 |
+
return cur.fetchall()
|
| 307 |
+
|
| 308 |
+
def list_classes_by_teacher(teacher_id:int):
|
| 309 |
+
with cursor() as cur:
|
| 310 |
+
cur.execute("""
|
| 311 |
+
SELECT s.*, c.code
|
| 312 |
+
FROM v_class_stats s
|
| 313 |
+
JOIN classes c USING (class_id)
|
| 314 |
+
WHERE s.teacher_id=%s
|
| 315 |
+
ORDER BY c.created_at DESC
|
| 316 |
+
""", (teacher_id,))
|
| 317 |
+
return cur.fetchall()
|
| 318 |
+
|
| 319 |
+
def get_class(class_id:int):
|
| 320 |
+
with cursor() as cur:
|
| 321 |
+
cur.execute("SELECT class_id, name, code, teacher_id FROM classes WHERE class_id=%s", (class_id,))
|
| 322 |
+
return cur.fetchone()
|
| 323 |
+
|
| 324 |
+
def class_student_metrics(class_id: int):
|
| 325 |
+
"""
|
| 326 |
+
Returns one row per student in the class with:
|
| 327 |
+
name, email, joined_at, lessons_completed, total_assigned_lessons,
|
| 328 |
+
avg_score (0..1), streak_days, total_xp
|
| 329 |
+
"""
|
| 330 |
+
with cursor() as cur:
|
| 331 |
+
cur.execute("""
|
| 332 |
+
/* total assigned lessons for the class */
|
| 333 |
+
WITH assigned AS (
|
| 334 |
+
SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
|
| 335 |
+
)
|
| 336 |
+
SELECT
|
| 337 |
+
cs.student_id,
|
| 338 |
+
u.name,
|
| 339 |
+
u.email,
|
| 340 |
+
cs.joined_at,
|
| 341 |
+
/* lessons completed by this student that were assigned to this class */
|
| 342 |
+
COALESCE(
|
| 343 |
+
(SELECT COUNT(*) FROM lesson_progress lp
|
| 344 |
+
WHERE lp.user_id = cs.student_id
|
| 345 |
+
AND lp.status = 'completed'
|
| 346 |
+
AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
|
| 347 |
+
), 0
|
| 348 |
+
) AS lessons_completed,
|
| 349 |
+
/* total lessons assigned to this class */
|
| 350 |
+
(SELECT COUNT(*) FROM assigned) AS total_assigned_lessons,
|
| 351 |
+
/* average quiz score only for submissions tied to this class */
|
| 352 |
+
COALESCE(sc.avg_score, 0) AS avg_score,
|
| 353 |
+
/* streak days from streaks table */
|
| 354 |
+
COALESCE(str.days, 0) AS streak_days,
|
| 355 |
+
/* total XP across the app */
|
| 356 |
+
COALESCE(xp.total_xp, 0) AS total_xp
|
| 357 |
+
FROM class_students cs
|
| 358 |
+
JOIN users u ON u.user_id = cs.student_id
|
| 359 |
+
LEFT JOIN (
|
| 360 |
+
SELECT s.student_id, AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_score
|
| 361 |
+
FROM submissions s
|
| 362 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 363 |
+
WHERE a.class_id = %s
|
| 364 |
+
GROUP BY s.student_id
|
| 365 |
+
) sc ON sc.student_id = cs.student_id
|
| 366 |
+
LEFT JOIN streaks str ON str.user_id = cs.student_id
|
| 367 |
+
LEFT JOIN (SELECT user_id, SUM(delta) AS total_xp FROM xp_log GROUP BY user_id) xp
|
| 368 |
+
ON xp.user_id = cs.student_id
|
| 369 |
+
WHERE cs.class_id = %s
|
| 370 |
+
ORDER BY u.name;
|
| 371 |
+
""", (class_id, class_id, class_id))
|
| 372 |
+
return cur.fetchall()
|
| 373 |
+
|
| 374 |
+
def level_from_xp(total_xp: int) -> int:
|
| 375 |
+
try:
|
| 376 |
+
xp = int(total_xp or 0)
|
| 377 |
+
except Exception:
|
| 378 |
+
xp = 0
|
| 379 |
+
return 1 + xp // 500
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def list_classes_for_student(student_id: int):
|
| 383 |
+
with cursor() as cur:
|
| 384 |
+
cur.execute("""
|
| 385 |
+
SELECT c.class_id, c.name, c.code, c.teacher_id,
|
| 386 |
+
t.name AS teacher_name, cs.joined_at
|
| 387 |
+
FROM class_students cs
|
| 388 |
+
JOIN classes c ON c.class_id = cs.class_id
|
| 389 |
+
JOIN users t ON t.user_id = c.teacher_id
|
| 390 |
+
WHERE cs.student_id = %s
|
| 391 |
+
ORDER BY cs.joined_at DESC
|
| 392 |
+
""", (student_id,))
|
| 393 |
+
return cur.fetchall()
|
| 394 |
+
|
| 395 |
+
def leave_class(student_id: int, class_id: int):
|
| 396 |
+
with cursor() as cur:
|
| 397 |
+
cur.execute("DELETE FROM class_students WHERE student_id=%s AND class_id=%s",
|
| 398 |
+
(student_id, class_id))
|
| 399 |
+
return True
|
| 400 |
+
|
| 401 |
+
def student_class_progress(student_id: int, class_id: int):
|
| 402 |
+
"""
|
| 403 |
+
Per-student view of progress inside ONE class.
|
| 404 |
+
Returns: dict(overall_progress 0..1, lessons_completed int,
|
| 405 |
+
total_assigned_lessons int, avg_score 0..1)
|
| 406 |
+
"""
|
| 407 |
+
with cursor() as cur:
|
| 408 |
+
# total distinct lessons assigned to this class
|
| 409 |
+
cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s",
|
| 410 |
+
(class_id,))
|
| 411 |
+
total_assigned = int((cur.fetchone() or {"n": 0})["n"])
|
| 412 |
+
|
| 413 |
+
# lessons completed among the class's assigned lessons
|
| 414 |
+
cur.execute("""
|
| 415 |
+
WITH assigned AS (SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s)
|
| 416 |
+
SELECT COUNT(*) AS n
|
| 417 |
+
FROM lesson_progress lp
|
| 418 |
+
WHERE lp.user_id = %s
|
| 419 |
+
AND lp.status = 'completed'
|
| 420 |
+
AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
|
| 421 |
+
""", (class_id, student_id))
|
| 422 |
+
completed = int((cur.fetchone() or {"n": 0})["n"])
|
| 423 |
+
|
| 424 |
+
# student’s avg quiz score but only for submissions tied to this class
|
| 425 |
+
cur.execute("""
|
| 426 |
+
SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
|
| 427 |
+
FROM submissions s
|
| 428 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 429 |
+
WHERE a.class_id = %s AND s.student_id = %s
|
| 430 |
+
""", (class_id, student_id))
|
| 431 |
+
avg_score = float((cur.fetchone() or {"avg_ratio": 0.0})["avg_ratio"] or 0.0)
|
| 432 |
+
|
| 433 |
+
overall = (completed / float(total_assigned)) if total_assigned else 0.0
|
| 434 |
+
return dict(
|
| 435 |
+
overall_progress=overall,
|
| 436 |
+
lessons_completed=completed,
|
| 437 |
+
total_assigned_lessons=total_assigned,
|
| 438 |
+
avg_score=avg_score
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
def student_assignments_for_class(student_id: int, class_id: int):
|
| 442 |
+
"""
|
| 443 |
+
All assignments in a class, annotated with THIS student's status/progress
|
| 444 |
+
and (if applicable) their quiz score for that assignment.
|
| 445 |
+
Deduplicates by lesson_id (keeps the most recent assignment per lesson).
|
| 446 |
+
"""
|
| 447 |
+
with cursor() as cur:
|
| 448 |
+
cur.execute("""
|
| 449 |
+
SELECT
|
| 450 |
+
a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
|
| 451 |
+
a.quiz_id, a.due_at,
|
| 452 |
+
COALESCE(lp.status,'not_started') AS status,
|
| 453 |
+
lp.current_pos,
|
| 454 |
+
/* student's latest submission on this assignment (if any) */
|
| 455 |
+
(SELECT MAX(s.submitted_at) FROM submissions s
|
| 456 |
+
WHERE s.assignment_id = a.assignment_id AND s.student_id = %s) AS last_submit_at,
|
| 457 |
+
(SELECT s2.score FROM submissions s2
|
| 458 |
+
WHERE s2.assignment_id = a.assignment_id AND s2.student_id = %s
|
| 459 |
+
ORDER BY s2.submitted_at DESC LIMIT 1) AS score,
|
| 460 |
+
(SELECT s3.total FROM submissions s3
|
| 461 |
+
WHERE s3.assignment_id = a.assignment_id AND s3.student_id = %s
|
| 462 |
+
ORDER BY s3.submitted_at DESC LIMIT 1) AS total
|
| 463 |
+
FROM (
|
| 464 |
+
SELECT
|
| 465 |
+
a.*,
|
| 466 |
+
ROW_NUMBER() OVER (
|
| 467 |
+
PARTITION BY a.lesson_id
|
| 468 |
+
ORDER BY a.created_at DESC, a.assignment_id DESC
|
| 469 |
+
) AS rn
|
| 470 |
+
FROM assignments a
|
| 471 |
+
WHERE a.class_id = %s
|
| 472 |
+
) AS a
|
| 473 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 474 |
+
LEFT JOIN lesson_progress lp
|
| 475 |
+
ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
|
| 476 |
+
WHERE a.rn = 1
|
| 477 |
+
ORDER BY a.created_at DESC
|
| 478 |
+
""", (student_id, student_id, student_id, class_id, student_id))
|
| 479 |
+
return cur.fetchall()
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def update_quiz(quiz_id:int, teacher_id:int, title:str, items:list[dict], settings:dict|None=None) -> bool:
|
| 485 |
+
with cursor() as cur:
|
| 486 |
+
# only the teacher who owns the linked lesson can edit
|
| 487 |
+
cur.execute("""
|
| 488 |
+
SELECT 1
|
| 489 |
+
FROM quizzes q
|
| 490 |
+
JOIN lessons l ON l.lesson_id = q.lesson_id
|
| 491 |
+
WHERE q.quiz_id = %s AND l.teacher_id = %s
|
| 492 |
+
LIMIT 1
|
| 493 |
+
""", (quiz_id, teacher_id))
|
| 494 |
+
if not cur.fetchone():
|
| 495 |
+
return False
|
| 496 |
+
|
| 497 |
+
cur.execute("UPDATE quizzes SET title=%s, settings=%s WHERE quiz_id=%s",
|
| 498 |
+
(title, json.dumps(settings or {}), quiz_id))
|
| 499 |
+
|
| 500 |
+
cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
|
| 501 |
+
for i, it in enumerate(items, start=1):
|
| 502 |
+
cur.execute("""
|
| 503 |
+
INSERT INTO quiz_items(quiz_id, position, question, options, answer_key, points)
|
| 504 |
+
VALUES (%s, %s, %s, %s, %s, %s)
|
| 505 |
+
""", (
|
| 506 |
+
quiz_id, i,
|
| 507 |
+
it["question"],
|
| 508 |
+
json.dumps(it.get("options", [])),
|
| 509 |
+
json.dumps(it.get("answer_key")), # single letter as JSON string
|
| 510 |
+
int(it.get("points", 1))
|
| 511 |
+
))
|
| 512 |
+
return True
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
def class_weekly_activity(class_id:int):
|
| 516 |
+
start = date.today() - timedelta(days=6)
|
| 517 |
+
with cursor() as cur:
|
| 518 |
+
cur.execute("""
|
| 519 |
+
SELECT DATE(lp.last_accessed) d, COUNT(*) n
|
| 520 |
+
FROM lesson_progress lp
|
| 521 |
+
JOIN class_students cs ON cs.student_id = lp.user_id
|
| 522 |
+
WHERE cs.class_id=%s AND lp.last_accessed >= %s
|
| 523 |
+
GROUP BY DATE(lp.last_accessed)
|
| 524 |
+
""", (class_id, start))
|
| 525 |
+
lessons = {r["d"]: r["n"] for r in cur.fetchall()}
|
| 526 |
+
|
| 527 |
+
cur.execute("""
|
| 528 |
+
SELECT DATE(s.submitted_at) d, COUNT(*) n
|
| 529 |
+
FROM submissions s
|
| 530 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 531 |
+
WHERE a.class_id=%s AND s.submitted_at >= %s
|
| 532 |
+
GROUP BY DATE(s.submitted_at)
|
| 533 |
+
""", (class_id, start))
|
| 534 |
+
quizzes = {r["d"]: r["n"] for r in cur.fetchall()}
|
| 535 |
+
|
| 536 |
+
cur.execute("""
|
| 537 |
+
SELECT DATE(g.started_at) d, COUNT(*) n
|
| 538 |
+
FROM game_sessions g
|
| 539 |
+
JOIN class_students cs ON cs.student_id = g.user_id
|
| 540 |
+
WHERE cs.class_id=%s AND g.started_at >= %s
|
| 541 |
+
GROUP BY DATE(g.started_at)
|
| 542 |
+
""", (class_id, start))
|
| 543 |
+
games = {r["d"]: r["n"] for r in cur.fetchall()}
|
| 544 |
+
|
| 545 |
+
out = []
|
| 546 |
+
for i in range(7):
|
| 547 |
+
d = start + timedelta(days=i)
|
| 548 |
+
out.append({
|
| 549 |
+
"date": d,
|
| 550 |
+
"lessons": lessons.get(d, 0),
|
| 551 |
+
"quizzes": quizzes.get(d, 0),
|
| 552 |
+
"games": games.get(d, 0),
|
| 553 |
+
})
|
| 554 |
+
return out
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def update_lesson(lesson_id:int, teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]) -> bool:
|
| 560 |
+
with cursor() as cur:
|
| 561 |
+
# ownership check
|
| 562 |
+
cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
|
| 563 |
+
if not cur.fetchone():
|
| 564 |
+
return False
|
| 565 |
+
|
| 566 |
+
cur.execute("""
|
| 567 |
+
UPDATE lessons
|
| 568 |
+
SET title=%s, description=%s, subject=%s, level=%s
|
| 569 |
+
WHERE lesson_id=%s AND teacher_id=%s
|
| 570 |
+
""", (title, description, subject, level_slug, lesson_id, teacher_id))
|
| 571 |
+
|
| 572 |
+
# simplest and safest: rebuild sections in order
|
| 573 |
+
cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
|
| 574 |
+
for i, sec in enumerate(sections, start=1):
|
| 575 |
+
cur.execute("""
|
| 576 |
+
INSERT INTO lesson_sections(lesson_id,position,title,content)
|
| 577 |
+
VALUES(%s,%s,%s,%s)
|
| 578 |
+
""", (lesson_id, i, sec.get("title"), sec.get("content")))
|
| 579 |
+
return True
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
# --- Class progress overview (overall progress, quiz performance, totals)
|
| 583 |
+
def class_progress_overview(class_id: int):
|
| 584 |
+
"""
|
| 585 |
+
Returns:
|
| 586 |
+
{
|
| 587 |
+
"overall_progress": 0..1,
|
| 588 |
+
"quiz_performance": 0..1,
|
| 589 |
+
"lessons_completed": int,
|
| 590 |
+
"class_xp": int
|
| 591 |
+
}
|
| 592 |
+
"""
|
| 593 |
+
with cursor() as cur:
|
| 594 |
+
# total distinct lessons assigned to this class
|
| 595 |
+
cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s", (class_id,))
|
| 596 |
+
total_assigned = int((cur.fetchone() or {"n": 0})["n"])
|
| 597 |
+
|
| 598 |
+
# number of enrolled students
|
| 599 |
+
cur.execute("SELECT COUNT(*) AS n FROM class_students WHERE class_id=%s", (class_id,))
|
| 600 |
+
num_students = int((cur.fetchone() or {"n": 0})["n"])
|
| 601 |
+
|
| 602 |
+
# sum of completed lessons by all students (for assigned lessons)
|
| 603 |
+
cur.execute("""
|
| 604 |
+
WITH assigned AS (
|
| 605 |
+
SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
|
| 606 |
+
), enrolled AS (
|
| 607 |
+
SELECT student_id FROM class_students WHERE class_id = %s
|
| 608 |
+
), per_student AS (
|
| 609 |
+
SELECT e.student_id,
|
| 610 |
+
COUNT(DISTINCT CASE
|
| 611 |
+
WHEN lp.status='completed' AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
|
| 612 |
+
THEN lp.lesson_id END) AS completed
|
| 613 |
+
FROM enrolled e
|
| 614 |
+
LEFT JOIN lesson_progress lp ON lp.user_id = e.student_id
|
| 615 |
+
GROUP BY e.student_id
|
| 616 |
+
)
|
| 617 |
+
SELECT COALESCE(SUM(completed),0) AS total_completed
|
| 618 |
+
FROM per_student
|
| 619 |
+
""", (class_id, class_id))
|
| 620 |
+
total_completed = int((cur.fetchone() or {"total_completed": 0})["total_completed"] or 0)
|
| 621 |
+
|
| 622 |
+
# quiz performance: average percentage for submissions tied to this class
|
| 623 |
+
cur.execute("""
|
| 624 |
+
SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
|
| 625 |
+
FROM submissions s
|
| 626 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 627 |
+
WHERE a.class_id = %s
|
| 628 |
+
""", (class_id,))
|
| 629 |
+
quiz_perf_row = cur.fetchone() or {"avg_ratio": 0}
|
| 630 |
+
quiz_perf = float(quiz_perf_row["avg_ratio"] or 0)
|
| 631 |
+
|
| 632 |
+
# total class XP (sum of xp for enrolled students)
|
| 633 |
+
cur.execute("""
|
| 634 |
+
SELECT COALESCE(SUM(x.delta),0) AS xp
|
| 635 |
+
FROM xp_log x
|
| 636 |
+
WHERE x.user_id IN (SELECT student_id FROM class_students WHERE class_id=%s)
|
| 637 |
+
""", (class_id,))
|
| 638 |
+
class_xp = int((cur.fetchone() or {"xp": 0})["xp"] or 0)
|
| 639 |
+
|
| 640 |
+
if total_assigned and num_students:
|
| 641 |
+
denominator = float(total_assigned * num_students)
|
| 642 |
+
overall = float(total_completed) / denominator
|
| 643 |
+
else:
|
| 644 |
+
overall = 0.0
|
| 645 |
+
|
| 646 |
+
return dict(
|
| 647 |
+
overall_progress=float(overall),
|
| 648 |
+
quiz_performance=float(quiz_perf),
|
| 649 |
+
lessons_completed=int(total_completed),
|
| 650 |
+
class_xp=int(class_xp),
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
# --- Recent student activity with total_xp for level badge
|
| 654 |
+
def class_recent_activity(class_id:int, limit:int=6, days:int=30):
|
| 655 |
+
"""
|
| 656 |
+
Returns latest activity rows with fields:
|
| 657 |
+
ts, kind('lesson'|'quiz'|'game'), student_id, student_name, item_title, extra, total_xp
|
| 658 |
+
"""
|
| 659 |
+
with cursor() as cur:
|
| 660 |
+
cur.execute(f"""
|
| 661 |
+
WITH enrolled AS (
|
| 662 |
+
SELECT student_id FROM class_students WHERE class_id = %s
|
| 663 |
+
),
|
| 664 |
+
xp AS (
|
| 665 |
+
SELECT user_id, COALESCE(SUM(delta),0) AS total_xp
|
| 666 |
+
FROM xp_log GROUP BY user_id
|
| 667 |
+
)
|
| 668 |
+
SELECT * FROM (
|
| 669 |
+
/* completed lessons */
|
| 670 |
+
SELECT lp.last_accessed AS ts,
|
| 671 |
+
'lesson' AS kind,
|
| 672 |
+
u.user_id AS student_id,
|
| 673 |
+
u.name AS student_name,
|
| 674 |
+
l.title AS item_title,
|
| 675 |
+
NULL AS extra,
|
| 676 |
+
COALESCE(xp.total_xp,0) AS total_xp
|
| 677 |
+
FROM lesson_progress lp
|
| 678 |
+
JOIN enrolled e ON e.student_id = lp.user_id
|
| 679 |
+
JOIN users u ON u.user_id = lp.user_id
|
| 680 |
+
JOIN lessons l ON l.lesson_id = lp.lesson_id
|
| 681 |
+
LEFT JOIN xp ON xp.user_id = u.user_id
|
| 682 |
+
WHERE lp.status = 'completed' AND lp.last_accessed >= NOW() - INTERVAL {days} DAY
|
| 683 |
+
|
| 684 |
+
UNION ALL
|
| 685 |
+
|
| 686 |
+
/* quiz submissions */
|
| 687 |
+
SELECT s.submitted_at AS ts,
|
| 688 |
+
'quiz' AS kind,
|
| 689 |
+
u.user_id AS student_id,
|
| 690 |
+
u.name AS student_name,
|
| 691 |
+
l.title AS item_title,
|
| 692 |
+
CONCAT(ROUND(s.score*100.0/NULLIF(s.total,0)),'%') AS extra,
|
| 693 |
+
COALESCE(xp.total_xp,0) AS total_xp
|
| 694 |
+
FROM submissions s
|
| 695 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id AND a.class_id = %s
|
| 696 |
+
JOIN users u ON u.user_id = s.student_id
|
| 697 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 698 |
+
LEFT JOIN xp ON xp.user_id = u.user_id
|
| 699 |
+
WHERE s.submitted_at >= NOW() - INTERVAL {days} DAY
|
| 700 |
+
|
| 701 |
+
UNION ALL
|
| 702 |
+
|
| 703 |
+
/* games */
|
| 704 |
+
SELECT g.started_at AS ts,
|
| 705 |
+
'game' AS kind,
|
| 706 |
+
u.user_id AS student_id,
|
| 707 |
+
u.name AS student_name,
|
| 708 |
+
g.game_slug AS item_title,
|
| 709 |
+
NULL AS extra,
|
| 710 |
+
COALESCE(xp.total_xp,0) AS total_xp
|
| 711 |
+
FROM game_sessions g
|
| 712 |
+
JOIN enrolled e ON e.student_id = g.user_id
|
| 713 |
+
JOIN users u ON u.user_id = g.user_id
|
| 714 |
+
LEFT JOIN xp ON xp.user_id = u.user_id
|
| 715 |
+
WHERE g.started_at >= NOW() - INTERVAL {days} DAY
|
| 716 |
+
) x
|
| 717 |
+
ORDER BY ts DESC
|
| 718 |
+
LIMIT %s
|
| 719 |
+
""", (class_id, class_id, limit))
|
| 720 |
+
return cur.fetchall()
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def list_quizzes_by_teacher(teacher_id:int):
|
| 725 |
+
with cursor() as cur:
|
| 726 |
+
cur.execute("""
|
| 727 |
+
SELECT q.quiz_id, q.title, q.created_at,
|
| 728 |
+
l.title AS lesson_title,
|
| 729 |
+
(SELECT COUNT(*) FROM quiz_items qi WHERE qi.quiz_id=q.quiz_id) AS num_items
|
| 730 |
+
FROM quizzes q
|
| 731 |
+
JOIN lessons l ON l.lesson_id=q.lesson_id
|
| 732 |
+
WHERE l.teacher_id=%s
|
| 733 |
+
ORDER BY q.created_at DESC
|
| 734 |
+
""", (teacher_id,))
|
| 735 |
+
return cur.fetchall()
|
| 736 |
+
|
| 737 |
+
def list_all_students_for_teacher(teacher_id:int):
|
| 738 |
+
with cursor() as cur:
|
| 739 |
+
cur.execute("""
|
| 740 |
+
SELECT DISTINCT u.user_id, u.name, u.email
|
| 741 |
+
FROM classes c
|
| 742 |
+
JOIN class_students cs ON cs.class_id=c.class_id
|
| 743 |
+
JOIN users u ON u.user_id=cs.student_id
|
| 744 |
+
WHERE c.teacher_id=%s
|
| 745 |
+
ORDER BY u.name
|
| 746 |
+
""", (teacher_id,))
|
| 747 |
+
return cur.fetchall()
|
| 748 |
+
|
| 749 |
+
# ----- ASSIGNEES (students) -----
|
| 750 |
+
|
| 751 |
+
def list_assigned_students_for_lesson(lesson_id:int):
|
| 752 |
+
with cursor() as cur:
|
| 753 |
+
cur.execute("""
|
| 754 |
+
WITH direct AS (
|
| 755 |
+
SELECT student_id FROM assignments
|
| 756 |
+
WHERE lesson_id=%s AND student_id IS NOT NULL
|
| 757 |
+
),
|
| 758 |
+
via_class AS (
|
| 759 |
+
SELECT cs.student_id
|
| 760 |
+
FROM assignments a
|
| 761 |
+
JOIN class_students cs ON cs.class_id=a.class_id
|
| 762 |
+
WHERE a.lesson_id=%s AND a.class_id IS NOT NULL
|
| 763 |
+
),
|
| 764 |
+
all_students AS (
|
| 765 |
+
SELECT student_id FROM direct
|
| 766 |
+
UNION
|
| 767 |
+
SELECT student_id FROM via_class
|
| 768 |
+
)
|
| 769 |
+
SELECT u.user_id, u.name, u.email
|
| 770 |
+
FROM users u
|
| 771 |
+
JOIN all_students s ON s.student_id=u.user_id
|
| 772 |
+
ORDER BY u.name
|
| 773 |
+
""", (lesson_id, lesson_id))
|
| 774 |
+
return cur.fetchall()
|
| 775 |
+
|
| 776 |
+
def list_assigned_students_for_quiz(quiz_id:int):
|
| 777 |
+
with cursor() as cur:
|
| 778 |
+
cur.execute("""
|
| 779 |
+
WITH direct AS (
|
| 780 |
+
SELECT student_id FROM assignments
|
| 781 |
+
WHERE quiz_id=%s AND student_id IS NOT NULL
|
| 782 |
+
),
|
| 783 |
+
via_class AS (
|
| 784 |
+
SELECT cs.student_id
|
| 785 |
+
FROM assignments a
|
| 786 |
+
JOIN class_students cs ON cs.class_id=a.class_id
|
| 787 |
+
WHERE a.quiz_id=%s AND a.class_id IS NOT NULL
|
| 788 |
+
),
|
| 789 |
+
all_students AS (
|
| 790 |
+
SELECT student_id FROM direct
|
| 791 |
+
UNION
|
| 792 |
+
SELECT student_id FROM via_class
|
| 793 |
+
)
|
| 794 |
+
SELECT u.user_id, u.name, u.email
|
| 795 |
+
FROM users u
|
| 796 |
+
JOIN all_students s ON s.student_id=u.user_id
|
| 797 |
+
ORDER BY u.name
|
| 798 |
+
""", (quiz_id, quiz_id))
|
| 799 |
+
return cur.fetchall()
|
| 800 |
+
|
| 801 |
+
# ----- ASSIGN ACTIONS -----
|
| 802 |
+
|
| 803 |
+
def assign_lesson_to_students(lesson_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
|
| 804 |
+
# bulk insert; quiz_id stays NULL
|
| 805 |
+
with cursor() as cur:
|
| 806 |
+
for sid in student_ids:
|
| 807 |
+
cur.execute("""
|
| 808 |
+
INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
|
| 809 |
+
VALUES(%s, NULL, %s, %s, %s)
|
| 810 |
+
ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
|
| 811 |
+
""", (lesson_id, sid, teacher_id, due_at))
|
| 812 |
+
return True
|
| 813 |
+
|
| 814 |
+
def assign_quiz_to_students(quiz_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
|
| 815 |
+
# get lesson_id for integrity
|
| 816 |
+
with cursor() as cur:
|
| 817 |
+
cur.execute("SELECT lesson_id FROM quizzes WHERE quiz_id=%s", (quiz_id,))
|
| 818 |
+
row = cur.fetchone()
|
| 819 |
+
if not row:
|
| 820 |
+
raise ValueError("Quiz not found")
|
| 821 |
+
lesson_id = row["lesson_id"]
|
| 822 |
+
for sid in student_ids:
|
| 823 |
+
cur.execute("""
|
| 824 |
+
INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
|
| 825 |
+
VALUES(%s, %s, %s, %s, %s)
|
| 826 |
+
ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
|
| 827 |
+
""", (lesson_id, quiz_id, sid, teacher_id, due_at))
|
| 828 |
+
return True
|
| 829 |
+
|
| 830 |
+
# ----- SAFE DELETE -----
|
| 831 |
+
|
| 832 |
+
def delete_lesson(lesson_id:int, teacher_id:int):
|
| 833 |
+
with cursor() as cur:
|
| 834 |
+
# ownership check
|
| 835 |
+
cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
|
| 836 |
+
if not cur.fetchone():
|
| 837 |
+
return False, "You can only delete own lesson."
|
| 838 |
+
# block if assigned or quizzed
|
| 839 |
+
cur.execute("SELECT COUNT(*) AS n FROM assignments WHERE lesson_id=%s", (lesson_id,))
|
| 840 |
+
if cur.fetchone()["n"] > 0:
|
| 841 |
+
return False, "Remove assignments first."
|
| 842 |
+
cur.execute("SELECT COUNT(*) AS n FROM quizzes WHERE lesson_id=%s", (lesson_id,))
|
| 843 |
+
if cur.fetchone()["n"] > 0:
|
| 844 |
+
return False, "Delete quizzes for this lesson first."
|
| 845 |
+
# delete sections then lesson
|
| 846 |
+
cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
|
| 847 |
+
cur.execute("DELETE FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
|
| 848 |
+
return True, "Deleted."
|
| 849 |
+
|
| 850 |
+
def delete_quiz(quiz_id:int, teacher_id:int):
|
| 851 |
+
with cursor() as cur:
|
| 852 |
+
cur.execute("""
|
| 853 |
+
SELECT 1
|
| 854 |
+
FROM quizzes q JOIN lessons l ON l.lesson_id=q.lesson_id
|
| 855 |
+
WHERE q.quiz_id=%s AND l.teacher_id=%s
|
| 856 |
+
""", (quiz_id, teacher_id))
|
| 857 |
+
if not cur.fetchone():
|
| 858 |
+
return False, "You can only delete own quiz."
|
| 859 |
+
cur.execute("SELECT COUNT(*) AS n FROM submissions WHERE quiz_id=%s", (quiz_id,))
|
| 860 |
+
if cur.fetchone()["n"] > 0:
|
| 861 |
+
return False, "This quiz has submissions. Deleting is blocked."
|
| 862 |
+
cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
|
| 863 |
+
cur.execute("DELETE FROM assignments WHERE quiz_id=%s", (quiz_id,))
|
| 864 |
+
cur.execute("DELETE FROM quizzes WHERE quiz_id=%s", (quiz_id,))
|
| 865 |
+
return True, "Deleted."
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
def _bump_game_stats(user_id:int, slug:str, *, gained_xp:int, matched:int|None=None, level_inc:int=0):
|
| 869 |
+
with cursor() as cur:
|
| 870 |
+
cur.execute("""
|
| 871 |
+
INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
|
| 872 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 873 |
+
ON DUPLICATE KEY UPDATE
|
| 874 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 875 |
+
matches = matches + VALUES(matches),
|
| 876 |
+
level = GREATEST(level, VALUES(level))
|
| 877 |
+
""", (user_id, slug, int(gained_xp), int(matched or 1), level_inc))
|
| 878 |
+
|
| 879 |
+
# ---------- LESSONS ----------
|
| 880 |
+
def create_lesson(teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]):
|
| 881 |
+
with cursor() as cur:
|
| 882 |
+
cur.execute("""
|
| 883 |
+
INSERT INTO lessons(teacher_id,title,description,subject,level,duration_min)
|
| 884 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 885 |
+
""", (teacher_id, title, description, subject, level_slug, 60))
|
| 886 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 887 |
+
lesson_id = cur.fetchone()["id"]
|
| 888 |
+
for i, sec in enumerate(sections, start=1):
|
| 889 |
+
cur.execute("""
|
| 890 |
+
INSERT INTO lesson_sections(lesson_id,position,title,content)
|
| 891 |
+
VALUES(%s,%s,%s,%s)
|
| 892 |
+
""", (lesson_id, i, sec.get("title"), sec.get("content")))
|
| 893 |
+
return lesson_id
|
| 894 |
+
|
| 895 |
+
def list_lessons_by_teacher(teacher_id:int):
|
| 896 |
+
with cursor() as cur:
|
| 897 |
+
cur.execute("SELECT * FROM lessons WHERE teacher_id=%s ORDER BY created_at DESC", (teacher_id,))
|
| 898 |
+
return cur.fetchall()
|
| 899 |
+
|
| 900 |
+
def get_lesson(lesson_id:int):
|
| 901 |
+
with cursor() as cur:
|
| 902 |
+
cur.execute("SELECT * FROM lessons WHERE lesson_id=%s", (lesson_id,))
|
| 903 |
+
lesson = cur.fetchone()
|
| 904 |
+
cur.execute("SELECT * FROM lesson_sections WHERE lesson_id=%s ORDER BY position", (lesson_id,))
|
| 905 |
+
sections = cur.fetchall()
|
| 906 |
+
return {"lesson": lesson, "sections": sections}
|
| 907 |
+
|
| 908 |
+
# ---------- QUIZZES ----------
|
| 909 |
+
def create_quiz(lesson_id:int, title:str, items:list[dict], settings:dict|None=None):
|
| 910 |
+
with cursor() as cur:
|
| 911 |
+
cur.execute("INSERT INTO quizzes(lesson_id,title,settings) VALUES(%s,%s,%s)",
|
| 912 |
+
(lesson_id, title, json.dumps(settings or {})))
|
| 913 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 914 |
+
quiz_id = cur.fetchone()["id"]
|
| 915 |
+
for i, it in enumerate(items, start=1):
|
| 916 |
+
cur.execute("""
|
| 917 |
+
INSERT INTO quiz_items(quiz_id,position,question,options,answer_key,points)
|
| 918 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 919 |
+
""", (quiz_id, i, it["question"], json.dumps(it.get("options", [])),
|
| 920 |
+
json.dumps(it.get("answer_key")), int(it.get("points", 1))))
|
| 921 |
+
return quiz_id
|
| 922 |
+
|
| 923 |
+
def get_quiz(quiz_id:int):
|
| 924 |
+
with cursor() as cur:
|
| 925 |
+
cur.execute("SELECT * FROM quizzes WHERE quiz_id=%s", (quiz_id,))
|
| 926 |
+
quiz = cur.fetchone()
|
| 927 |
+
cur.execute("SELECT * FROM quiz_items WHERE quiz_id=%s ORDER BY position", (quiz_id,))
|
| 928 |
+
items = cur.fetchall()
|
| 929 |
+
return {"quiz": quiz, "items": items}
|
| 930 |
+
|
| 931 |
+
# ---------- ASSIGNMENTS ----------
|
| 932 |
+
def assign_to_class(lesson_id:int, quiz_id:int|None, class_id:int, teacher_id:int, due_at:str|None=None):
|
| 933 |
+
with cursor() as cur:
|
| 934 |
+
cur.execute("""
|
| 935 |
+
INSERT INTO assignments(lesson_id,quiz_id,class_id,assigned_by,due_at)
|
| 936 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 937 |
+
""", (lesson_id, quiz_id, class_id, teacher_id, due_at))
|
| 938 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 939 |
+
return cur.fetchone()["id"]
|
| 940 |
+
|
| 941 |
+
def assign_to_student(lesson_id:int, quiz_id:int|None, student_id:int, teacher_id:int, due_at:str|None=None):
|
| 942 |
+
with cursor() as cur:
|
| 943 |
+
cur.execute("""
|
| 944 |
+
INSERT INTO assignments(lesson_id,quiz_id,student_id,assigned_by,due_at)
|
| 945 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 946 |
+
""", (lesson_id, quiz_id, student_id, teacher_id, due_at))
|
| 947 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 948 |
+
return cur.fetchone()["id"]
|
| 949 |
+
|
| 950 |
+
def list_assignments_for_student(student_id:int):
|
| 951 |
+
with cursor() as cur:
|
| 952 |
+
cur.execute("""
|
| 953 |
+
SELECT
|
| 954 |
+
a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
|
| 955 |
+
a.quiz_id, a.due_at,
|
| 956 |
+
COALESCE(lp.status,'not_started') AS status,
|
| 957 |
+
lp.current_pos
|
| 958 |
+
FROM (
|
| 959 |
+
SELECT
|
| 960 |
+
a.*,
|
| 961 |
+
ROW_NUMBER() OVER (
|
| 962 |
+
PARTITION BY a.lesson_id
|
| 963 |
+
ORDER BY a.created_at DESC, a.assignment_id DESC
|
| 964 |
+
) AS rn
|
| 965 |
+
FROM assignments a
|
| 966 |
+
WHERE a.student_id = %s
|
| 967 |
+
OR a.class_id IN (SELECT class_id FROM class_students WHERE student_id = %s)
|
| 968 |
+
) AS a
|
| 969 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 970 |
+
LEFT JOIN lesson_progress lp
|
| 971 |
+
ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
|
| 972 |
+
WHERE a.rn = 1
|
| 973 |
+
ORDER BY a.created_at DESC
|
| 974 |
+
""", (student_id, student_id, student_id))
|
| 975 |
+
return cur.fetchall()
|
| 976 |
+
|
| 977 |
+
|
| 978 |
+
# ---------- PROGRESS and SUBMISSIONS ----------
|
| 979 |
+
def save_progress(user_id:int, lesson_id:int, current_pos:int, status:str):
|
| 980 |
+
with cursor() as cur:
|
| 981 |
+
cur.execute("""
|
| 982 |
+
INSERT INTO lesson_progress(user_id,lesson_id,current_pos,status)
|
| 983 |
+
VALUES(%s,%s,%s,%s)
|
| 984 |
+
ON DUPLICATE KEY UPDATE current_pos=VALUES(current_pos), status=VALUES(status)
|
| 985 |
+
""", (user_id, lesson_id, current_pos, status))
|
| 986 |
+
return True
|
| 987 |
+
|
| 988 |
+
def submit_quiz(student_id:int, assignment_id:int, quiz_id:int, score:int, total:int, details:dict):
|
| 989 |
+
with cursor() as cur:
|
| 990 |
+
cur.execute("""
|
| 991 |
+
INSERT INTO submissions(assignment_id,quiz_id,student_id,score,total,details)
|
| 992 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 993 |
+
ON DUPLICATE KEY UPDATE score=VALUES(score), total=VALUES(total), details=VALUES(details), submitted_at=CURRENT_TIMESTAMP
|
| 994 |
+
""", (assignment_id, quiz_id, student_id, score, total, json.dumps(details)))
|
| 995 |
+
|
| 996 |
+
return True
|
| 997 |
+
|
| 998 |
+
# ---------- DASHBOARD SHORTCUTS ----------
|
| 999 |
+
def teacher_tiles(teacher_id:int):
|
| 1000 |
+
with cursor() as cur:
|
| 1001 |
+
cur.execute("SELECT * FROM v_class_stats WHERE teacher_id=%s", (teacher_id,))
|
| 1002 |
+
rows = cur.fetchall()
|
| 1003 |
+
total_students = sum(r["total_students"] for r in rows)
|
| 1004 |
+
lessons_created = _count_lessons(teacher_id)
|
| 1005 |
+
# use simple averages; adjust later as needed
|
| 1006 |
+
class_avg = round(sum(r["class_avg"] for r in rows)/len(rows), 2) if rows else 0
|
| 1007 |
+
active_students = sum(1 for r in rows if r.get("recent_submissions",0) > 0)
|
| 1008 |
+
return dict(total_students=total_students, class_avg=class_avg, lessons_created=lessons_created, active_students=active_students)
|
| 1009 |
+
|
| 1010 |
+
def _count_lessons(teacher_id:int):
|
| 1011 |
+
with cursor() as cur:
|
| 1012 |
+
cur.execute("SELECT COUNT(*) AS n FROM lessons WHERE teacher_id=%s", (teacher_id,))
|
| 1013 |
+
return cur.fetchone()["n"]
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
# --- XP and streak helpers ---
|
| 1017 |
+
def user_xp_and_level(user_id: int, base: int = 500):
|
| 1018 |
+
with cursor() as cur:
|
| 1019 |
+
cur.execute("SELECT COALESCE(SUM(delta),0) AS xp FROM xp_log WHERE user_id=%s", (user_id,))
|
| 1020 |
+
xp = int((cur.fetchone() or {"xp": 0})["xp"])
|
| 1021 |
+
cur.execute("SELECT COALESCE(days,0) AS days FROM streaks WHERE user_id=%s", (user_id,))
|
| 1022 |
+
streak = int((cur.fetchone() or {"days": 0})["days"])
|
| 1023 |
+
|
| 1024 |
+
# level math
|
| 1025 |
+
level = max(1, xp // base + 1)
|
| 1026 |
+
start_of_level = (level - 1) * base
|
| 1027 |
+
into = xp - start_of_level
|
| 1028 |
+
need = base
|
| 1029 |
+
# exact boundary should flip level and reset progress
|
| 1030 |
+
if into == need:
|
| 1031 |
+
level += 1
|
| 1032 |
+
into = 0
|
| 1033 |
+
|
| 1034 |
+
return {
|
| 1035 |
+
"xp": xp, # lifetime XP from the DB
|
| 1036 |
+
"level": level, # current level
|
| 1037 |
+
"into": into, # XP inside this level
|
| 1038 |
+
"need": need, # XP needed to reach next level
|
| 1039 |
+
"streak": streak,
|
| 1040 |
+
}
|
| 1041 |
+
|
| 1042 |
+
def recent_lessons_for_student(user_id:int, limit:int=5):
|
| 1043 |
+
with cursor() as cur:
|
| 1044 |
+
cur.execute("""
|
| 1045 |
+
SELECT l.title,
|
| 1046 |
+
CASE WHEN lp.status='completed' THEN 100
|
| 1047 |
+
WHEN lp.current_pos IS NULL THEN 0
|
| 1048 |
+
ELSE LEAST(95, lp.current_pos * 10)
|
| 1049 |
+
END AS progress
|
| 1050 |
+
FROM lessons l
|
| 1051 |
+
LEFT JOIN lesson_progress lp
|
| 1052 |
+
ON lp.lesson_id=l.lesson_id AND lp.user_id=%s
|
| 1053 |
+
WHERE l.lesson_id IN (
|
| 1054 |
+
SELECT lesson_id FROM assignments
|
| 1055 |
+
WHERE student_id=%s
|
| 1056 |
+
OR class_id IN (SELECT class_id FROM class_students WHERE student_id=%s)
|
| 1057 |
+
)
|
| 1058 |
+
ORDER BY l.created_at DESC
|
| 1059 |
+
LIMIT %s
|
| 1060 |
+
""", (user_id, user_id, user_id, limit))
|
| 1061 |
+
return cur.fetchall()
|
| 1062 |
+
|
| 1063 |
+
def student_quiz_average(student_id: int) -> int:
|
| 1064 |
+
"""
|
| 1065 |
+
Returns the student's average quiz percentage (0–100) using the latest
|
| 1066 |
+
submission per quiz from the `submissions` table.
|
| 1067 |
+
"""
|
| 1068 |
+
with cursor() as cur:
|
| 1069 |
+
cur.execute("""
|
| 1070 |
+
WITH latest AS (
|
| 1071 |
+
SELECT quiz_id, MAX(submitted_at) AS last_ts
|
| 1072 |
+
FROM submissions
|
| 1073 |
+
WHERE student_id = %s
|
| 1074 |
+
GROUP BY quiz_id
|
| 1075 |
+
)
|
| 1076 |
+
SELECT ROUND(AVG(s.score * 100.0 / NULLIF(s.total,0))) AS pct
|
| 1077 |
+
FROM latest t
|
| 1078 |
+
JOIN submissions s
|
| 1079 |
+
ON s.quiz_id = t.quiz_id
|
| 1080 |
+
AND s.submitted_at = t.last_ts
|
| 1081 |
+
WHERE s.student_id = %s
|
| 1082 |
+
""", (student_id, student_id))
|
| 1083 |
+
row = cur.fetchone() or {}
|
| 1084 |
+
return int(row.get("pct") or 0)
|
| 1085 |
+
|
| 1086 |
+
# --- Generic XP bump and streak touch ---
|
| 1087 |
+
def add_xp(user_id:int, delta:int, source:str, meta:dict|None=None):
|
| 1088 |
+
with cursor() as cur:
|
| 1089 |
+
cur.execute(
|
| 1090 |
+
"INSERT INTO xp_log(user_id,source,delta,meta) VALUES(%s,%s,%s,%s)",
|
| 1091 |
+
(user_id, source, int(delta), json.dumps(meta or {}))
|
| 1092 |
+
)
|
| 1093 |
+
# streak touch
|
| 1094 |
+
cur.execute("SELECT days, last_active FROM streaks WHERE user_id=%s", (user_id,))
|
| 1095 |
+
row = cur.fetchone()
|
| 1096 |
+
today = date.today()
|
| 1097 |
+
if not row:
|
| 1098 |
+
cur.execute("INSERT INTO streaks(user_id,days,last_active) VALUES(%s,%s,%s)", (user_id, 1, today))
|
| 1099 |
+
else:
|
| 1100 |
+
last = row["last_active"]
|
| 1101 |
+
days = int(row["days"] or 0)
|
| 1102 |
+
if last is None or last < today:
|
| 1103 |
+
# if we missed a day, reset to 1 else +1
|
| 1104 |
+
if last and (today - last) > timedelta(days=1):
|
| 1105 |
+
days = 1
|
| 1106 |
+
else:
|
| 1107 |
+
days = max(1, days + 1)
|
| 1108 |
+
cur.execute("UPDATE streaks SET days=%s,last_active=%s WHERE user_id=%s", (days, today, user_id))
|
| 1109 |
+
|
| 1110 |
+
# -- leaderboard helpders ---
|
| 1111 |
+
|
| 1112 |
+
def leaderboard_for_class(class_id: int, limit: int = 10):
|
| 1113 |
+
"""
|
| 1114 |
+
Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
|
| 1115 |
+
Sorted by XP (desc) for students in a specific class.
|
| 1116 |
+
"""
|
| 1117 |
+
with cursor() as cur:
|
| 1118 |
+
cur.execute("""
|
| 1119 |
+
SELECT
|
| 1120 |
+
u.user_id,
|
| 1121 |
+
u.name,
|
| 1122 |
+
COALESCE(x.total_xp, 0) AS xp
|
| 1123 |
+
FROM class_students cs
|
| 1124 |
+
JOIN users u ON u.user_id = cs.student_id
|
| 1125 |
+
LEFT JOIN (
|
| 1126 |
+
SELECT user_id, SUM(delta) AS total_xp
|
| 1127 |
+
FROM xp_log
|
| 1128 |
+
GROUP BY user_id
|
| 1129 |
+
) x ON x.user_id = u.user_id
|
| 1130 |
+
WHERE cs.class_id = %s
|
| 1131 |
+
ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
|
| 1132 |
+
LIMIT %s
|
| 1133 |
+
""", (class_id, limit))
|
| 1134 |
+
rows = cur.fetchall() or []
|
| 1135 |
+
# attach levels using curve
|
| 1136 |
+
for r in rows:
|
| 1137 |
+
r["level"] = level_from_xp(r.get("xp", 0))
|
| 1138 |
+
return rows
|
| 1139 |
+
|
| 1140 |
+
|
| 1141 |
+
def leaderboard_global(limit: int = 10):
|
| 1142 |
+
"""
|
| 1143 |
+
Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
|
| 1144 |
+
Top students across the whole app by XP.
|
| 1145 |
+
"""
|
| 1146 |
+
with cursor() as cur:
|
| 1147 |
+
cur.execute("""
|
| 1148 |
+
SELECT
|
| 1149 |
+
u.user_id,
|
| 1150 |
+
u.name,
|
| 1151 |
+
COALESCE(x.total_xp, 0) AS xp
|
| 1152 |
+
FROM users u
|
| 1153 |
+
LEFT JOIN (
|
| 1154 |
+
SELECT user_id, SUM(delta) AS total_xp
|
| 1155 |
+
FROM xp_log
|
| 1156 |
+
GROUP BY user_id
|
| 1157 |
+
) x ON x.user_id = u.user_id
|
| 1158 |
+
WHERE u.role_slug = 'student'
|
| 1159 |
+
ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
|
| 1160 |
+
LIMIT %s
|
| 1161 |
+
""", (limit,))
|
| 1162 |
+
rows = cur.fetchall() or []
|
| 1163 |
+
for r in rows:
|
| 1164 |
+
r["level"] = level_from_xp(r.get("xp", 0))
|
| 1165 |
+
return rows
|
| 1166 |
+
|
| 1167 |
+
|
| 1168 |
+
|
| 1169 |
+
|
| 1170 |
+
# --- Game logging helpers ---
|
| 1171 |
+
def record_money_match_play(user_id:int, *, target:int, total:int, elapsed_ms:int, matched:bool, gained_xp:int):
|
| 1172 |
+
with cursor() as cur:
|
| 1173 |
+
cur.execute("""
|
| 1174 |
+
INSERT INTO game_sessions(user_id,game_slug,target,total,elapsed_ms,matched,gained_xp,ended_at)
|
| 1175 |
+
VALUES(%s,'money_match',%s,%s,%s,%s,%s,NOW())
|
| 1176 |
+
""", (user_id, target, total, elapsed_ms, 1 if matched else 0, gained_xp))
|
| 1177 |
+
cur.execute("""
|
| 1178 |
+
INSERT INTO money_match_history(user_id,target,total,elapsed_ms,gained_xp,matched)
|
| 1179 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 1180 |
+
""", (user_id, target, total, elapsed_ms, gained_xp, 1 if matched else 0))
|
| 1181 |
+
cur.execute("""
|
| 1182 |
+
INSERT INTO money_match_stats(user_id,total_xp,matches,best_time_ms,best_target)
|
| 1183 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 1184 |
+
ON DUPLICATE KEY UPDATE
|
| 1185 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1186 |
+
matches = matches + VALUES(matches),
|
| 1187 |
+
best_time_ms = LEAST(COALESCE(best_time_ms, VALUES(best_time_ms)), VALUES(best_time_ms)),
|
| 1188 |
+
best_target = COALESCE(best_target, VALUES(best_target))
|
| 1189 |
+
""", (user_id, gained_xp, 1 if matched else 0, elapsed_ms if matched else None, target if matched else None))
|
| 1190 |
+
|
| 1191 |
+
_bump_game_stats(user_id, "money_match", gained_xp=gained_xp, matched=1 if matched else 0)
|
| 1192 |
+
add_xp(user_id, gained_xp, "game", {"game":"money_match","target":target,"total":total,"elapsed_ms":elapsed_ms,"matched":matched})
|
| 1193 |
+
|
| 1194 |
+
def record_budget_builder_save(user_id:int, *, weekly_allowance:int, allocations:list[dict]):
|
| 1195 |
+
total_allocated = sum(int(x.get("amount",0)) for x in allocations)
|
| 1196 |
+
remaining = int(weekly_allowance) - total_allocated
|
| 1197 |
+
gained_xp = 150 if remaining == 0 else 100 if remaining > 0 else 50
|
| 1198 |
+
with cursor() as cur:
|
| 1199 |
+
cur.execute("""
|
| 1200 |
+
INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
|
| 1201 |
+
VALUES(%s,'budget_builder',%s,NOW())
|
| 1202 |
+
""", (user_id, gained_xp))
|
| 1203 |
+
cur.execute("""
|
| 1204 |
+
INSERT INTO budget_builder_history(user_id,weekly_allowance,allocations,total_allocated,remaining,gained_xp)
|
| 1205 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 1206 |
+
""", (user_id, weekly_allowance, json.dumps(allocations), total_allocated, remaining, gained_xp))
|
| 1207 |
+
cur.execute("""
|
| 1208 |
+
INSERT INTO budget_builder_stats(user_id,total_xp,plays,best_balance)
|
| 1209 |
+
VALUES(%s,%s,1,%s)
|
| 1210 |
+
ON DUPLICATE KEY UPDATE
|
| 1211 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1212 |
+
plays = plays + 1,
|
| 1213 |
+
best_balance = GREATEST(COALESCE(best_balance, 0), VALUES(best_balance))
|
| 1214 |
+
""", (user_id, gained_xp, remaining))
|
| 1215 |
+
|
| 1216 |
+
_bump_game_stats(user_id, "budget_builder", gained_xp=gained_xp, matched=1)
|
| 1217 |
+
add_xp(user_id, gained_xp, "game", {"game":"budget_builder","remaining":remaining})
|
| 1218 |
+
|
| 1219 |
+
def record_debt_dilemma_round(
|
| 1220 |
+
user_id:int, *,
|
| 1221 |
+
level:int, round_no:int,
|
| 1222 |
+
wallet:int, health:int, happiness:int, credit_score:int,
|
| 1223 |
+
event_json:dict, outcome:str, gained_xp:int
|
| 1224 |
+
):
|
| 1225 |
+
with cursor() as cur:
|
| 1226 |
+
cur.execute("""
|
| 1227 |
+
INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
|
| 1228 |
+
VALUES(%s,'debt_dilemma',%s,NOW())
|
| 1229 |
+
""", (user_id, gained_xp))
|
| 1230 |
+
cur.execute("""
|
| 1231 |
+
INSERT INTO debt_dilemma_history(user_id,level,round_no,wallet,health,happiness,credit_score,event_json,outcome,gained_xp)
|
| 1232 |
+
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
| 1233 |
+
""", (user_id, level, round_no, wallet, health, happiness, credit_score, json.dumps(event_json or {}), outcome, gained_xp))
|
| 1234 |
+
cur.execute("""
|
| 1235 |
+
INSERT INTO debt_dilemma_stats(user_id,total_xp,plays,highest_level,last_outcome)
|
| 1236 |
+
VALUES(%s,%s,1,%s,%s)
|
| 1237 |
+
ON DUPLICATE KEY UPDATE
|
| 1238 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1239 |
+
plays = plays + 1,
|
| 1240 |
+
highest_level = GREATEST(COALESCE(highest_level,0), VALUES(highest_level)),
|
| 1241 |
+
last_outcome = VALUES(last_outcome)
|
| 1242 |
+
""", (user_id, gained_xp, level, outcome))
|
| 1243 |
+
|
| 1244 |
+
# Treat a completed month/level as a "match"
|
| 1245 |
+
_bump_game_stats(user_id, "debt_dilemma", gained_xp=gained_xp, matched=1, level_inc=level)
|
| 1246 |
+
add_xp(user_id, gained_xp, "game", {
|
| 1247 |
+
"game":"debt_dilemma","level":level,"round":round_no,"outcome":outcome
|
| 1248 |
+
})
|
| 1249 |
+
|
| 1250 |
+
|
| 1251 |
+
def record_profit_puzzle_result(
|
| 1252 |
+
user_id:int, *,
|
| 1253 |
+
scenario_id:str,
|
| 1254 |
+
title:str,
|
| 1255 |
+
units:int, price:int, cost:int,
|
| 1256 |
+
user_answer:float, actual_profit:float,
|
| 1257 |
+
is_correct:bool, gained_xp:int
|
| 1258 |
+
):
|
| 1259 |
+
with cursor() as cur:
|
| 1260 |
+
# generic session row for cross-game views
|
| 1261 |
+
cur.execute("""
|
| 1262 |
+
INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
|
| 1263 |
+
VALUES(%s,'profit_puzzle',%s,NOW())
|
| 1264 |
+
""", (user_id, int(gained_xp)))
|
| 1265 |
+
|
| 1266 |
+
# detailed history
|
| 1267 |
+
cur.execute("""
|
| 1268 |
+
INSERT INTO profit_puzzle_history
|
| 1269 |
+
(user_id,scenario_id,title,units,price,cost,user_answer,actual_profit,is_correct,gained_xp)
|
| 1270 |
+
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
| 1271 |
+
""", (user_id, scenario_id, title, int(units), int(price), int(cost),
|
| 1272 |
+
float(user_answer), float(actual_profit), 1 if is_correct else 0, int(gained_xp)))
|
| 1273 |
+
|
| 1274 |
+
# per-game stats
|
| 1275 |
+
cur.execute("""
|
| 1276 |
+
INSERT INTO profit_puzzle_stats(user_id,total_xp,plays,correct,last_score)
|
| 1277 |
+
VALUES(%s,%s,1,%s,%s)
|
| 1278 |
+
ON DUPLICATE KEY UPDATE
|
| 1279 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1280 |
+
plays = plays + 1,
|
| 1281 |
+
correct = correct + VALUES(correct),
|
| 1282 |
+
last_score = VALUES(last_score),
|
| 1283 |
+
last_played = CURRENT_TIMESTAMP
|
| 1284 |
+
""", (user_id, int(gained_xp), 1 if is_correct else 0, int(gained_xp)))
|
| 1285 |
+
|
| 1286 |
+
# game_stats rollup like other games
|
| 1287 |
+
cur.execute("""
|
| 1288 |
+
INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
|
| 1289 |
+
VALUES(%s,'profit_puzzle',%s,%s,1)
|
| 1290 |
+
ON DUPLICATE KEY UPDATE
|
| 1291 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1292 |
+
matches = matches + VALUES(matches)
|
| 1293 |
+
""", (user_id, int(gained_xp), 1 if is_correct else 0))
|
| 1294 |
+
|
| 1295 |
+
# global XP and streak
|
| 1296 |
+
add_xp(user_id, int(gained_xp), "game",
|
| 1297 |
+
{"game":"profit_puzzle","scenario":scenario_id,"correct":bool(is_correct)})
|
| 1298 |
+
|
| 1299 |
+
# --- Profit Puzzle logging ---
|
| 1300 |
+
def record_profit_puzzle_progress(user_id:int, *, scenario_title:str, correct:bool, gained_xp:int):
|
| 1301 |
+
"""
|
| 1302 |
+
Log a Profit Puzzle step and bump XP.
|
| 1303 |
+
- Writes to generic game_sessions and game_stats
|
| 1304 |
+
- Writes to xp_log via add_xp
|
| 1305 |
+
"""
|
| 1306 |
+
with cursor() as cur:
|
| 1307 |
+
# session line item
|
| 1308 |
+
cur.execute("""
|
| 1309 |
+
INSERT INTO game_sessions(user_id, game_slug, gained_xp, ended_at)
|
| 1310 |
+
VALUES(%s, 'profit_puzzle', %s, NOW())
|
| 1311 |
+
""", (user_id, int(gained_xp)))
|
| 1312 |
+
|
| 1313 |
+
# aggregate by game
|
| 1314 |
+
cur.execute("""
|
| 1315 |
+
INSERT INTO game_stats(user_id, game_slug, total_xp, matches, level)
|
| 1316 |
+
VALUES(%s, 'profit_puzzle', %s, %s, 1)
|
| 1317 |
+
ON DUPLICATE KEY UPDATE
|
| 1318 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1319 |
+
matches = matches + VALUES(matches)
|
| 1320 |
+
""", (user_id, int(gained_xp), 1 if correct else 0))
|
| 1321 |
+
|
| 1322 |
+
add_xp(
|
| 1323 |
+
user_id,
|
| 1324 |
+
int(gained_xp),
|
| 1325 |
+
"game",
|
| 1326 |
+
{"game": "profit_puzzle", "scenario": scenario_title, "correct": bool(correct)}
|
| 1327 |
+
)
|
utils/quizdata.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
quizzes_data = {
|
| 2 |
+
1: {
|
| 3 |
+
"title": "Module 1: Budgeting Basics",
|
| 4 |
+
"description": "Learn the fundamentals of budgeting and money management",
|
| 5 |
+
"level": "Beginner",
|
| 6 |
+
"duration": "15 min",
|
| 7 |
+
"questions": [
|
| 8 |
+
{
|
| 9 |
+
"question": "Which of these is considered a 'need' rather than a 'want'?",
|
| 10 |
+
"options": ["Latest smartphone", "Designer clothes", "Basic shelter and housing", "Streaming subscriptions"],
|
| 11 |
+
"answer": "Basic shelter and housing",
|
| 12 |
+
"explanation": "Shelter is essential for survival, while others are lifestyle wants."
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"question": "What is the 50/30/20 budgeting rule?",
|
| 16 |
+
"options": [
|
| 17 |
+
"50% needs, 30% wants, 20% savings",
|
| 18 |
+
"30% needs, 50% wants, 20% savings",
|
| 19 |
+
"20% needs, 50% savings, 30% wants",
|
| 20 |
+
"40% needs, 40% wants, 20% savings"
|
| 21 |
+
],
|
| 22 |
+
"answer": "50% needs, 30% wants, 20% savings",
|
| 23 |
+
"explanation": "This rule allocates income to essentials, lifestyle, and savings/debt repayment."
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"question": "What should you do first when creating a budget?",
|
| 27 |
+
"options": [
|
| 28 |
+
"Track your income and expenses for a month",
|
| 29 |
+
"Get a loan",
|
| 30 |
+
"Buy less food",
|
| 31 |
+
"Invest in stocks"
|
| 32 |
+
],
|
| 33 |
+
"answer": "Track your income and expenses for a month",
|
| 34 |
+
"explanation": "Tracking helps you understand spending patterns before planning a budget."
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"question": "How often should you review and update your budget?",
|
| 38 |
+
"options": ["Every week", "Monthly", "Yearly", "Never"],
|
| 39 |
+
"answer": "Monthly",
|
| 40 |
+
"explanation": "Budgets should be reviewed monthly to reflect financial changes."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"question": "What percentage of income should ideally go to housing costs?",
|
| 44 |
+
"options": ["10% or less", "30% or less", "50% or less", "70% or less"],
|
| 45 |
+
"answer": "30% or less",
|
| 46 |
+
"explanation": "Experts recommend keeping housing costs at 30% or less of gross income."
|
| 47 |
+
},
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
2: {
|
| 51 |
+
"title": "Module 2: Saving & Emergency Funds",
|
| 52 |
+
"description": "Master the art of saving and building financial security",
|
| 53 |
+
"level": "Beginner",
|
| 54 |
+
"duration": "12 min",
|
| 55 |
+
"questions": [
|
| 56 |
+
{
|
| 57 |
+
"question": "What is the primary purpose of an emergency fund?",
|
| 58 |
+
"options": [
|
| 59 |
+
"To buy luxury items",
|
| 60 |
+
"To cover unexpected expenses",
|
| 61 |
+
"To invest in the stock market",
|
| 62 |
+
"To pay monthly bills"
|
| 63 |
+
],
|
| 64 |
+
"answer": "To cover unexpected expenses",
|
| 65 |
+
"explanation": "An emergency fund provides financial security during unplanned situations."
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"question": "How much should you ideally save in an emergency fund?",
|
| 69 |
+
"options": [
|
| 70 |
+
"1 month of expenses",
|
| 71 |
+
"3–6 months of expenses",
|
| 72 |
+
"12 months of expenses",
|
| 73 |
+
"No fixed amount"
|
| 74 |
+
],
|
| 75 |
+
"answer": "3–6 months of expenses",
|
| 76 |
+
"explanation": "Experts recommend saving enough to cover 3–6 months of living expenses."
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"question": "Where should you keep your emergency fund?",
|
| 80 |
+
"options": [
|
| 81 |
+
"In a checking or savings account",
|
| 82 |
+
"In risky stocks",
|
| 83 |
+
"In real estate",
|
| 84 |
+
"Locked in a retirement account"
|
| 85 |
+
],
|
| 86 |
+
"answer": "In a checking or savings account",
|
| 87 |
+
"explanation": "Emergency funds should be liquid and easily accessible."
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"question": "What is the difference between saving and investing?",
|
| 91 |
+
"options": [
|
| 92 |
+
"Saving is riskier than investing",
|
| 93 |
+
"Investing is short-term, saving is long-term",
|
| 94 |
+
"Saving is for safety, investing is for growth",
|
| 95 |
+
"They are the same thing"
|
| 96 |
+
],
|
| 97 |
+
"answer": "Saving is for safety, investing is for growth",
|
| 98 |
+
"explanation": "Savings are secure, while investments aim for higher returns with more risk."
|
| 99 |
+
},
|
| 100 |
+
]
|
| 101 |
+
},
|
| 102 |
+
3: {
|
| 103 |
+
"title": "Module 3: Investment Fundamentals",
|
| 104 |
+
"description": "Understanding the basics of investing and growing wealth",
|
| 105 |
+
"level": "Intermediate",
|
| 106 |
+
"duration": "20 min",
|
| 107 |
+
"questions": [
|
| 108 |
+
{
|
| 109 |
+
"question": "Which of these is considered a low-risk investment?",
|
| 110 |
+
"options": ["Stocks", "Bonds", "Cryptocurrency", "Options trading"],
|
| 111 |
+
"answer": "Bonds",
|
| 112 |
+
"explanation": "Bonds are generally safer than stocks and other volatile investments."
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"question": "What is diversification in investing?",
|
| 116 |
+
"options": [
|
| 117 |
+
"Putting all money into one stock",
|
| 118 |
+
"Spreading investments across different assets",
|
| 119 |
+
"Investing only in foreign companies",
|
| 120 |
+
"Investing only in real estate"
|
| 121 |
+
],
|
| 122 |
+
"answer": "Spreading investments across different assets",
|
| 123 |
+
"explanation": "Diversification reduces risk by not relying on a single asset."
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"question": "Which investment typically has the highest risk?",
|
| 127 |
+
"options": ["Savings account", "Treasury bonds", "Stocks", "Cryptocurrency"],
|
| 128 |
+
"answer": "Cryptocurrency",
|
| 129 |
+
"explanation": "Cryptocurrencies are highly volatile compared to traditional investments."
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"question": "What does 'compound interest' mean?",
|
| 133 |
+
"options": [
|
| 134 |
+
"Interest earned only on the original deposit",
|
| 135 |
+
"Interest earned on both the deposit and accumulated interest",
|
| 136 |
+
"A type of tax on investments",
|
| 137 |
+
"A penalty for late payments"
|
| 138 |
+
],
|
| 139 |
+
"answer": "Interest earned on both the deposit and accumulated interest",
|
| 140 |
+
"explanation": "Compound interest accelerates growth by earning interest on interest."
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"question": "Which of these is considered a retirement investment account?",
|
| 144 |
+
"options": ["401(k)", "Credit card", "Checking account", "Car loan"],
|
| 145 |
+
"answer": "401(k)",
|
| 146 |
+
"explanation": "A 401(k) is a retirement savings account that offers tax advantages."
|
| 147 |
+
},
|
| 148 |
+
]
|
| 149 |
+
},
|
| 150 |
+
4: {
|
| 151 |
+
"title": "Module 4: Credit & Debt Management",
|
| 152 |
+
"description": "Learn how to manage credit and debt responsibly",
|
| 153 |
+
"level": "Intermediate",
|
| 154 |
+
"duration": "18 min",
|
| 155 |
+
"questions": [
|
| 156 |
+
{
|
| 157 |
+
"question": "Which of these improves your credit score?",
|
| 158 |
+
"options": [
|
| 159 |
+
"Paying bills on time",
|
| 160 |
+
"Maxing out your credit cards",
|
| 161 |
+
"Closing old credit accounts",
|
| 162 |
+
"Missing payments occasionally"
|
| 163 |
+
],
|
| 164 |
+
"answer": "Paying bills on time",
|
| 165 |
+
"explanation": "On-time payments are the biggest factor in a good credit score."
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"question": "What is a common consequence of only making minimum credit card payments?",
|
| 169 |
+
"options": [
|
| 170 |
+
"You avoid all interest charges",
|
| 171 |
+
"It takes longer to pay off debt with more interest",
|
| 172 |
+
"Your credit score immediately improves",
|
| 173 |
+
"You save money in the long run"
|
| 174 |
+
],
|
| 175 |
+
"answer": "It takes longer to pay off debt with more interest",
|
| 176 |
+
"explanation": "Minimum payments extend repayment time and increase total interest costs."
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"question": "What is a 'debt-to-income ratio'?",
|
| 180 |
+
"options": [
|
| 181 |
+
"Your income compared to your expenses",
|
| 182 |
+
"Your monthly debt compared to your monthly income",
|
| 183 |
+
"Your total debt compared to your savings",
|
| 184 |
+
"Your credit score number"
|
| 185 |
+
],
|
| 186 |
+
"answer": "Your monthly debt compared to your monthly income",
|
| 187 |
+
"explanation": "Lenders use this ratio to assess your ability to manage debt."
|
| 188 |
+
},
|
| 189 |
+
{
|
| 190 |
+
"question": "Which strategy is best for paying off multiple debts quickly?",
|
| 191 |
+
"options": [
|
| 192 |
+
"Debt snowball (pay smallest debts first)",
|
| 193 |
+
"Debt avalanche (pay highest interest debts first)",
|
| 194 |
+
"Pay all debts equally",
|
| 195 |
+
"Ignore debts until they go away"
|
| 196 |
+
],
|
| 197 |
+
"answer": "Debt avalanche (pay highest interest debts first)",
|
| 198 |
+
"explanation": "Debt avalanche minimizes interest payments by targeting high-interest debt first."
|
| 199 |
+
},
|
| 200 |
+
]
|
| 201 |
+
},
|
| 202 |
+
5: {
|
| 203 |
+
"title": "General Financial Knowledge",
|
| 204 |
+
"description": "Test your overall financial literacy across all topics",
|
| 205 |
+
"level": "Intermediate",
|
| 206 |
+
"duration": "25 min",
|
| 207 |
+
"questions": [
|
| 208 |
+
{
|
| 209 |
+
"question": "What does 'inflation' mean?",
|
| 210 |
+
"options": [
|
| 211 |
+
"Decrease in overall price levels",
|
| 212 |
+
"Increase in overall price levels",
|
| 213 |
+
"A government tax increase",
|
| 214 |
+
"Stock market growth"
|
| 215 |
+
],
|
| 216 |
+
"answer": "Increase in overall price levels",
|
| 217 |
+
"explanation": "Inflation is the general rise in prices over time."
|
| 218 |
+
},
|
| 219 |
+
{
|
| 220 |
+
"question": "What is the main purpose of insurance?",
|
| 221 |
+
"options": [
|
| 222 |
+
"To generate investment returns",
|
| 223 |
+
"To protect against financial loss",
|
| 224 |
+
"To avoid paying taxes",
|
| 225 |
+
"To increase monthly expenses"
|
| 226 |
+
],
|
| 227 |
+
"answer": "To protect against financial loss",
|
| 228 |
+
"explanation": "Insurance transfers financial risk from you to the insurer."
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"question": "What is net income?",
|
| 232 |
+
"options": [
|
| 233 |
+
"Total income before taxes",
|
| 234 |
+
"Income after taxes and deductions",
|
| 235 |
+
"The same as gross income",
|
| 236 |
+
"Investment profits only"
|
| 237 |
+
],
|
| 238 |
+
"answer": "Income after taxes and deductions",
|
| 239 |
+
"explanation": "Net income is the money you take home after deductions."
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"question": "Which financial product typically has the highest interest rate?",
|
| 243 |
+
"options": ["Mortgage loan", "Credit card", "Student loan", "Car loan"],
|
| 244 |
+
"answer": "Credit card",
|
| 245 |
+
"explanation": "Credit cards usually have higher interest rates than other types of loans."
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"question": "What is diversification in finance?",
|
| 249 |
+
"options": [
|
| 250 |
+
"Spreading money across different assets to reduce risk",
|
| 251 |
+
"Putting all money into one high-performing stock",
|
| 252 |
+
"Avoiding investments completely",
|
| 253 |
+
"Buying only government bonds"
|
| 254 |
+
],
|
| 255 |
+
"answer": "Spreading money across different assets to reduce risk",
|
| 256 |
+
"explanation": "Diversification reduces exposure to risk by investing in various asset classes."
|
| 257 |
+
},
|
| 258 |
+
]
|
| 259 |
+
},
|
| 260 |
+
}
|