lanna_lalala;- commited on
Commit ·
bdbcf73
1
Parent(s): 758272f
feat: import backend code (clean history)
Browse files- app.py +308 -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 +175 -0
- dashboards/teacher_db.py +204 -0
- isrgrootx1.pem +31 -0
- tools/__init__.py +0 -0
- utils/db.py +1266 -0
- utils/graph.py +0 -0
- utils/quizdata.py +260 -0
- utils/seed.py +0 -0
app.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import streamlit as st
|
| 3 |
+
st.set_page_config(
|
| 4 |
+
page_title="Financial Education App",
|
| 5 |
+
page_icon="💹",
|
| 6 |
+
layout="centered",
|
| 7 |
+
initial_sidebar_state="expanded"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
from secrets import choice
|
| 11 |
+
from dashboards import student_db,teacher_db
|
| 12 |
+
from phase.Student_view import chatbot, lesson, quiz, game, teacherlink
|
| 13 |
+
from phase.Teacher_view import classmanage,studentlist,contentmanage
|
| 14 |
+
from phase.Student_view.games import profitpuzzle
|
| 15 |
+
from utils import db
|
| 16 |
+
import os
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
load_dotenv()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# --- SESSION STATE INITIALIZATION ---
|
| 24 |
+
for key, default in [("user", None), ("current_page", "Welcome"),
|
| 25 |
+
("xp", 2450), ("streak", 7), ("current_game", None),
|
| 26 |
+
("temp_user", None)]:
|
| 27 |
+
if key not in st.session_state:
|
| 28 |
+
st.session_state[key] = default
|
| 29 |
+
|
| 30 |
+
# --- NAVIGATION ---
|
| 31 |
+
def setup_navigation():
|
| 32 |
+
if st.session_state.user:
|
| 33 |
+
public_pages = ["Welcome", "Login"]
|
| 34 |
+
else:
|
| 35 |
+
public_pages = ["Welcome", "Signup", "Login"]
|
| 36 |
+
|
| 37 |
+
nav_choice = st.sidebar.selectbox(
|
| 38 |
+
"Go to",
|
| 39 |
+
public_pages,
|
| 40 |
+
index=public_pages.index(st.session_state.current_page) if st.session_state.current_page in public_pages else 0
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# --- if quiz is in progress, show progress tracker ---
|
| 44 |
+
if st.session_state.get("current_page") == "Quiz":
|
| 45 |
+
qid = st.session_state.get("selected_quiz")
|
| 46 |
+
if qid is not None:
|
| 47 |
+
try:
|
| 48 |
+
quiz.show_quiz_progress_sidebar(qid) # renders into sidebar
|
| 49 |
+
except Exception:
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# --- if profit puzzle game is in progress, show progress tracker ---
|
| 54 |
+
if (
|
| 55 |
+
st.session_state.get("current_page") == "Game"
|
| 56 |
+
and st.session_state.get("current_game") == "profit_puzzle"
|
| 57 |
+
):
|
| 58 |
+
profitpuzzle.show_profit_progress_sidebar()
|
| 59 |
+
|
| 60 |
+
# Only override if user is already on a public page
|
| 61 |
+
if st.session_state.current_page in public_pages:
|
| 62 |
+
st.session_state.current_page = nav_choice
|
| 63 |
+
|
| 64 |
+
if st.session_state.user:
|
| 65 |
+
st.sidebar.markdown("---")
|
| 66 |
+
st.sidebar.subheader("Dashboard")
|
| 67 |
+
role = st.session_state.user["role"]
|
| 68 |
+
|
| 69 |
+
if role == "Student":
|
| 70 |
+
if st.sidebar.button("📊 Student Dashboard"):
|
| 71 |
+
st.session_state.current_page = "Student Dashboard"
|
| 72 |
+
if st.sidebar.button("📘 Lessons"):
|
| 73 |
+
st.session_state.current_page = "Lessons"
|
| 74 |
+
if st.sidebar.button("📝 Quiz"):
|
| 75 |
+
st.session_state.current_page = "Quiz"
|
| 76 |
+
if st.sidebar.button("💬 Chatbot"):
|
| 77 |
+
st.session_state.current_page = "Chatbot"
|
| 78 |
+
if st.sidebar.button("🏆 Game"):
|
| 79 |
+
st.session_state.current_page = "Game"
|
| 80 |
+
if st.sidebar.button("⌨️ Teacher Link"):
|
| 81 |
+
st.session_state.current_page = "Teacher Link"
|
| 82 |
+
|
| 83 |
+
elif role == "Teacher":
|
| 84 |
+
if st.sidebar.button("📚 Teacher Dashboard"):
|
| 85 |
+
st.session_state.current_page = "Teacher Dashboard"
|
| 86 |
+
if st.sidebar.button("Class management"):
|
| 87 |
+
st.session_state.current_page = "Class management"
|
| 88 |
+
if st.sidebar.button("Students List"):
|
| 89 |
+
st.session_state.current_page = "Students List"
|
| 90 |
+
if st.sidebar.button("Content Management"):
|
| 91 |
+
st.session_state.current_page = "Content Management"
|
| 92 |
+
|
| 93 |
+
if st.sidebar.button("Logout"):
|
| 94 |
+
st.session_state.user = None
|
| 95 |
+
st.session_state.current_page = "Welcome"
|
| 96 |
+
st.rerun()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# --- ROUTING ---
|
| 100 |
+
def main():
|
| 101 |
+
setup_navigation()
|
| 102 |
+
page = st.session_state.current_page
|
| 103 |
+
|
| 104 |
+
# --- WELCOME PAGE ---
|
| 105 |
+
if page == "Welcome":
|
| 106 |
+
st.title("💹 Welcome to FinEdu App")
|
| 107 |
+
if st.session_state.user:
|
| 108 |
+
st.success(f"Welcome back, {st.session_state.user['name']}! ✅")
|
| 109 |
+
st.write(
|
| 110 |
+
"This app helps you improve your **financial education and numeracy skills**. \n"
|
| 111 |
+
"👉 Use the sidebar to **Signup** or **Login** to get started."
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
# --- SIGNUP PAGE ---
|
| 115 |
+
elif page == "Signup":
|
| 116 |
+
st.title("📝 Signup")
|
| 117 |
+
|
| 118 |
+
# remember the picked role between reruns
|
| 119 |
+
if "signup_role" not in st.session_state:
|
| 120 |
+
st.session_state.signup_role = None
|
| 121 |
+
|
| 122 |
+
if st.session_state.user:
|
| 123 |
+
st.success(f"Already logged in as {st.session_state.user['name']}.")
|
| 124 |
+
st.stop()
|
| 125 |
+
|
| 126 |
+
# Step 1: choose role
|
| 127 |
+
if not st.session_state.signup_role:
|
| 128 |
+
st.subheader("Who are you signing up as?")
|
| 129 |
+
c1, c2 = st.columns(2)
|
| 130 |
+
with c1:
|
| 131 |
+
if st.button("👩🎓 Student", use_container_width=True):
|
| 132 |
+
st.session_state.signup_role = "Student"
|
| 133 |
+
st.rerun()
|
| 134 |
+
with c2:
|
| 135 |
+
if st.button("👨🏫 Teacher", use_container_width=True):
|
| 136 |
+
st.session_state.signup_role = "Teacher"
|
| 137 |
+
st.rerun()
|
| 138 |
+
|
| 139 |
+
st.info("Pick your role to continue with the correct form.")
|
| 140 |
+
st.stop()
|
| 141 |
+
|
| 142 |
+
role = st.session_state.signup_role
|
| 143 |
+
|
| 144 |
+
# Step 2a: Student form
|
| 145 |
+
if role == "Student":
|
| 146 |
+
st.subheader("Student Signup")
|
| 147 |
+
with st.form("student_signup_form", clear_on_submit=False):
|
| 148 |
+
name = st.text_input("Full Name")
|
| 149 |
+
email = st.text_input("Email")
|
| 150 |
+
password = st.text_input("Password", type="password")
|
| 151 |
+
country = st.selectbox("Country", ["Jamaica", "USA", "UK", "India", "Canada", "Other"])
|
| 152 |
+
level = st.selectbox("Level", ["Beginner", "Intermediate", "Advanced"])
|
| 153 |
+
submitted = st.form_submit_button("Create Student Account")
|
| 154 |
+
|
| 155 |
+
if submitted:
|
| 156 |
+
if not (name.strip() and email.strip() and password.strip()):
|
| 157 |
+
st.error("⚠️ Please complete all required fields.")
|
| 158 |
+
st.stop()
|
| 159 |
+
|
| 160 |
+
conn = db.get_db_connection()
|
| 161 |
+
if not conn:
|
| 162 |
+
st.error("❌ Unable to connect to the database.")
|
| 163 |
+
st.stop()
|
| 164 |
+
try:
|
| 165 |
+
ok = db.create_student(
|
| 166 |
+
name=name, email=email, password=password,
|
| 167 |
+
level_label=level, country_label=country
|
| 168 |
+
)
|
| 169 |
+
if ok:
|
| 170 |
+
st.success("✅ Signup successful! Please go to the **Login** page to continue.")
|
| 171 |
+
st.session_state.current_page = "Login"
|
| 172 |
+
st.session_state.signup_role = None
|
| 173 |
+
st.rerun()
|
| 174 |
+
else:
|
| 175 |
+
st.error("❌ Failed to create user. Email may already exist.")
|
| 176 |
+
finally:
|
| 177 |
+
conn.close()
|
| 178 |
+
|
| 179 |
+
# Step 2b: Teacher form
|
| 180 |
+
elif role == "Teacher":
|
| 181 |
+
st.subheader("Teacher Signup")
|
| 182 |
+
with st.form("teacher_signup_form", clear_on_submit=False):
|
| 183 |
+
title = st.selectbox("Title", ["Mr", "Ms", "Miss", "Mrs", "Dr", "Prof", "Other"])
|
| 184 |
+
name = st.text_input("Full Name")
|
| 185 |
+
email = st.text_input("Email")
|
| 186 |
+
password = st.text_input("Password", type="password")
|
| 187 |
+
submitted = st.form_submit_button("Create Teacher Account")
|
| 188 |
+
|
| 189 |
+
if submitted:
|
| 190 |
+
if not (title.strip() and name.strip() and email.strip() and password.strip()):
|
| 191 |
+
st.error("⚠️ Please complete all required fields.")
|
| 192 |
+
st.stop()
|
| 193 |
+
|
| 194 |
+
conn = db.get_db_connection()
|
| 195 |
+
if not conn:
|
| 196 |
+
st.error("❌ Unable to connect to the database.")
|
| 197 |
+
st.stop()
|
| 198 |
+
try:
|
| 199 |
+
ok = db.create_teacher(
|
| 200 |
+
title=title, name=name, email=email, password=password
|
| 201 |
+
)
|
| 202 |
+
if ok:
|
| 203 |
+
st.success("✅ Signup successful! Please go to the **Login** page to continue.")
|
| 204 |
+
st.session_state.current_page = "Login"
|
| 205 |
+
st.session_state.signup_role = None
|
| 206 |
+
st.rerun()
|
| 207 |
+
else:
|
| 208 |
+
st.error("❌ Failed to create user. Email may already exist.")
|
| 209 |
+
finally:
|
| 210 |
+
conn.close()
|
| 211 |
+
|
| 212 |
+
# Allow changing role without going back manually
|
| 213 |
+
if st.button("⬅️ Choose a different role"):
|
| 214 |
+
st.session_state.signup_role = None
|
| 215 |
+
st.rerun()
|
| 216 |
+
|
| 217 |
+
# --- LOGIN PAGE ---
|
| 218 |
+
elif page == "Login":
|
| 219 |
+
st.title("🔑 Login")
|
| 220 |
+
if st.session_state.user:
|
| 221 |
+
st.success(f"Welcome, {st.session_state.user['name']}! ✅")
|
| 222 |
+
else:
|
| 223 |
+
with st.form("login_form"):
|
| 224 |
+
email = st.text_input("Email")
|
| 225 |
+
password = st.text_input("Password", type="password")
|
| 226 |
+
submit = st.form_submit_button("Login")
|
| 227 |
+
|
| 228 |
+
if submit:
|
| 229 |
+
conn = db.get_db_connection()
|
| 230 |
+
if not conn:
|
| 231 |
+
st.error("❌ Unable to connect to the database.")
|
| 232 |
+
else:
|
| 233 |
+
try:
|
| 234 |
+
user = db.check_password(email, password)
|
| 235 |
+
if user:
|
| 236 |
+
st.session_state.user = {
|
| 237 |
+
"user_id": user["user_id"],
|
| 238 |
+
"name": user["name"],
|
| 239 |
+
"role": user["role"], # "Student" or "Teacher"
|
| 240 |
+
"email": user["email"],
|
| 241 |
+
}
|
| 242 |
+
st.success(f"🎉 Logged in as {user['name']} ({user['role']})")
|
| 243 |
+
st.session_state.current_page = (
|
| 244 |
+
"Student Dashboard" if user["role"] == "Student" else "Teacher Dashboard"
|
| 245 |
+
)
|
| 246 |
+
st.rerun()
|
| 247 |
+
else:
|
| 248 |
+
st.error("❌ Incorrect email or password, or account not found.")
|
| 249 |
+
finally:
|
| 250 |
+
conn.close()
|
| 251 |
+
|
| 252 |
+
# --- STUDENT DASHBOARD ---
|
| 253 |
+
elif page == "Student Dashboard":
|
| 254 |
+
if not st.session_state.user:
|
| 255 |
+
st.error("❌ Please login first.")
|
| 256 |
+
st.session_state.current_page = "Login"
|
| 257 |
+
st.rerun()
|
| 258 |
+
elif st.session_state.user["role"] != "Student":
|
| 259 |
+
st.error("🚫 Only students can access this page.")
|
| 260 |
+
st.session_state.current_page = "Welcome"
|
| 261 |
+
st.rerun()
|
| 262 |
+
else:
|
| 263 |
+
student_db.show_student_dashboard()
|
| 264 |
+
|
| 265 |
+
# --- TEACHER DASHBOARD ---
|
| 266 |
+
elif page == "Teacher Dashboard":
|
| 267 |
+
if not st.session_state.user:
|
| 268 |
+
st.error("❌ Please login first.")
|
| 269 |
+
st.session_state.current_page = "Login"
|
| 270 |
+
st.rerun()
|
| 271 |
+
elif st.session_state.user["role"] != "Teacher":
|
| 272 |
+
st.error("🚫 Only teachers can access this page.")
|
| 273 |
+
st.session_state.current_page = "Welcome"
|
| 274 |
+
st.rerun()
|
| 275 |
+
else:
|
| 276 |
+
teacher_db.show_teacher_dashboard()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# --- PRIVATE PAGES ---
|
| 280 |
+
private_pages_map = {
|
| 281 |
+
"Lessons": lesson.show_page,
|
| 282 |
+
"Quiz": quiz.show_page,
|
| 283 |
+
"Chatbot": chatbot.show_page,
|
| 284 |
+
"Game": game.show_games,
|
| 285 |
+
"Teacher Link": teacherlink.show_code,
|
| 286 |
+
"Class management": classmanage.show_page,
|
| 287 |
+
"Students List": studentlist.show_page,
|
| 288 |
+
"Content Management": contentmanage.show_page
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
if page in private_pages_map:
|
| 292 |
+
if not st.session_state.user:
|
| 293 |
+
st.error("❌ Please login first.")
|
| 294 |
+
st.session_state.current_page = "Login"
|
| 295 |
+
st.rerun()
|
| 296 |
+
elif page in ["Lessons", "Quiz", "Chatbot", "Game", "Teacher Link"] and st.session_state.user["role"] == "Student":
|
| 297 |
+
private_pages_map[page]()
|
| 298 |
+
elif page in ["Class management", "Students List", "Content Management"] and st.session_state.user["role"] == "Teacher":
|
| 299 |
+
private_pages_map[page]()
|
| 300 |
+
else:
|
| 301 |
+
st.error("🚫 You don’t have access to this page.")
|
| 302 |
+
st.session_state.current_page = "Welcome"
|
| 303 |
+
st.rerun()
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
if __name__ == "__main__":
|
| 308 |
+
main()
|
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,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 6 |
+
# --- Load external CSS ---
|
| 7 |
+
def load_css(file_name: str):
|
| 8 |
+
try:
|
| 9 |
+
with open(file_name, "r", encoding="utf-8") as f:
|
| 10 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 11 |
+
except FileNotFoundError:
|
| 12 |
+
st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
|
| 13 |
+
|
| 14 |
+
def show_student_dashboard():
|
| 15 |
+
# Load CSS
|
| 16 |
+
css_path = os.path.join("assets", "styles.css")
|
| 17 |
+
load_css(css_path)
|
| 18 |
+
|
| 19 |
+
# Current user
|
| 20 |
+
user = st.session_state.user
|
| 21 |
+
name = user["name"]
|
| 22 |
+
student_id = user["user_id"]
|
| 23 |
+
|
| 24 |
+
# --- Real metrics from DB ---
|
| 25 |
+
# Requires helper funcs in utils/db.py: user_xp_and_level, recent_lessons_for_student, list_assignments_for_student
|
| 26 |
+
stats = dbapi.user_xp_and_level(student_id) if hasattr(dbapi, "user_xp_and_level") else {"xp": 0, "level": 1, "streak": 0}
|
| 27 |
+
xp = int(stats.get("xp", 0))
|
| 28 |
+
level = int(stats.get("level", 1))
|
| 29 |
+
study_streak = int(stats.get("streak", 0))
|
| 30 |
+
|
| 31 |
+
# Cap for the visual bar
|
| 32 |
+
max_xp = max(500, ((xp // 500) + 1) * 500)
|
| 33 |
+
|
| 34 |
+
# Assignments for “My Work”
|
| 35 |
+
rows = dbapi.list_assignments_for_student(student_id) if hasattr(dbapi, "list_assignments_for_student") else []
|
| 36 |
+
|
| 37 |
+
def _pct_from_row(r: dict):
|
| 38 |
+
sp = r.get("score_pct")
|
| 39 |
+
if sp is not None:
|
| 40 |
+
try:
|
| 41 |
+
return int(round(float(sp)))
|
| 42 |
+
except Exception:
|
| 43 |
+
pass
|
| 44 |
+
s, t = r.get("score"), r.get("total")
|
| 45 |
+
if s is not None and t not in (None, 0):
|
| 46 |
+
try:
|
| 47 |
+
return int(round((float(s) / float(t)) * 100))
|
| 48 |
+
except Exception:
|
| 49 |
+
return None
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
quiz_score = dbapi.student_quiz_average(student_id) if hasattr(dbapi, "student_quiz_average") else 0
|
| 53 |
+
lessons_completed = sum(1 for r in rows if r.get("status") == "completed" or _pct_from_row(r) == 100)
|
| 54 |
+
total_lessons = len(rows)
|
| 55 |
+
|
| 56 |
+
# Recent lessons assigned to this student
|
| 57 |
+
recent_lessons = dbapi.recent_lessons_for_student(student_id, limit=5) if hasattr(dbapi, "recent_lessons_for_student") else []
|
| 58 |
+
|
| 59 |
+
# Daily Challenge derived from real data
|
| 60 |
+
challenge_difficulty = "Easy" if level < 3 else ("Medium" if level < 6 else "Hard")
|
| 61 |
+
challenge_title = "Complete 1 quiz with 80%+"
|
| 62 |
+
challenge_desc = "Prove you remember yesterday's key points."
|
| 63 |
+
challenge_progress = 100 if quiz_score >= 80 else 0
|
| 64 |
+
reward = "+50 XP"
|
| 65 |
+
time_left = "Ends 11:59 PM"
|
| 66 |
+
|
| 67 |
+
# Achievements from real data
|
| 68 |
+
achievements = [
|
| 69 |
+
{"title": "First Steps", "desc": "Complete your first lesson", "earned": lessons_completed > 0},
|
| 70 |
+
{"title": "Quiz Whiz", "desc": "Score 80%+ on any quiz", "earned": quiz_score >= 80},
|
| 71 |
+
{"title": "On a Roll", "desc": "Study 3 days in a row", "earned": study_streak >= 3},
|
| 72 |
+
{"title": "Consistency", "desc": "Finish 5 assignments", "earned": total_lessons >= 5 and lessons_completed >= 5},
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
# --- Welcome Card ---
|
| 76 |
+
st.markdown(
|
| 77 |
+
f"""
|
| 78 |
+
<div class="welcome-card">
|
| 79 |
+
<h2>Welcome back, {name}!</h2>
|
| 80 |
+
<p style="font-size: 20px;">{"Ready to continue your financial journey?" if lessons_completed > 0 else "Start your financial journey."}</p>
|
| 81 |
+
</div>
|
| 82 |
+
""",
|
| 83 |
+
unsafe_allow_html=True
|
| 84 |
+
)
|
| 85 |
+
st.write("")
|
| 86 |
+
|
| 87 |
+
# --- Quick Action Buttons ---
|
| 88 |
+
actions = [
|
| 89 |
+
("📚 Start a Lesson", "Lessons"),
|
| 90 |
+
("📝 Attempt a Quiz", "Quiz"),
|
| 91 |
+
("💬 Talk to AI Tutor", "Chatbot"),
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
# 5 columns: spacer, button, button, button, spacer
|
| 95 |
+
cols = st.columns([1, 2, 2, 2, 1])
|
| 96 |
+
for i, (label, page) in enumerate(actions):
|
| 97 |
+
with cols[i+1]: # skip the left spacer
|
| 98 |
+
if st.button(label, key=f"action_{i}"):
|
| 99 |
+
st.session_state.current_page = page
|
| 100 |
+
st.rerun()
|
| 101 |
+
|
| 102 |
+
st.write("")
|
| 103 |
+
|
| 104 |
+
# --- Progress Summary Cards ---
|
| 105 |
+
progress_cols = st.columns(3)
|
| 106 |
+
progress_cols[0].metric("📘 Lessons Completed", f"{lessons_completed}/{total_lessons}")
|
| 107 |
+
progress_cols[1].metric("📊 Quiz Score", f"{quiz_score}/100")
|
| 108 |
+
progress_cols[2].metric("🔥 Study Streak", f"{study_streak} days")
|
| 109 |
+
st.write("")
|
| 110 |
+
|
| 111 |
+
# --- My Assignments (from DB) ---
|
| 112 |
+
st.markdown("---")
|
| 113 |
+
st.subheader("📘 My Work")
|
| 114 |
+
if not rows:
|
| 115 |
+
st.info("No assignments yet. Ask your teacher to assign a lesson.")
|
| 116 |
+
else:
|
| 117 |
+
for a in rows:
|
| 118 |
+
title = a.get("title", "Untitled")
|
| 119 |
+
subj = a.get("subject", "General")
|
| 120 |
+
lvl = a.get("level", "Beginner")
|
| 121 |
+
status = a.get("status", "not_started")
|
| 122 |
+
due = a.get("due_at")
|
| 123 |
+
due_txt = f" · Due {str(due)[:10]}" if due else ""
|
| 124 |
+
st.markdown(f"**{title}** · {subj} · {lvl}{due_txt}")
|
| 125 |
+
st.caption(f"Status: {status} · Resume at section {a.get('current_pos', 1)}")
|
| 126 |
+
st.markdown("---")
|
| 127 |
+
|
| 128 |
+
# --- XP Bar ---
|
| 129 |
+
pct = 0 if max_xp <= 0 else min(100, int(round((xp / max_xp) * 100)))
|
| 130 |
+
st.markdown(
|
| 131 |
+
f"""
|
| 132 |
+
<div class="xp-card">
|
| 133 |
+
<span class="xp-level">Level {level}</span>
|
| 134 |
+
<span class="xp-text">{xp} / {max_xp} XP</span>
|
| 135 |
+
<div class="xp-bar">
|
| 136 |
+
<div class="xp-fill" style="width: {pct}%;"></div>
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
""",
|
| 140 |
+
unsafe_allow_html=True
|
| 141 |
+
)
|
| 142 |
+
st.write("")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
# --- Recent Lessons & Achievements ---
|
| 146 |
+
col1, col2 = st.columns(2)
|
| 147 |
+
|
| 148 |
+
def _progress_value(v):
|
| 149 |
+
try:
|
| 150 |
+
f = float(v)
|
| 151 |
+
except Exception:
|
| 152 |
+
return 0.0
|
| 153 |
+
# streamlit accepts 0–1 float; if someone passes 0–100, scale it
|
| 154 |
+
return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
|
| 155 |
+
|
| 156 |
+
with col1:
|
| 157 |
+
st.subheader("📖 Recent Lessons")
|
| 158 |
+
st.caption("Continue where you left off")
|
| 159 |
+
if not recent_lessons:
|
| 160 |
+
st.info("No recent lessons yet.")
|
| 161 |
+
else:
|
| 162 |
+
for lesson in recent_lessons:
|
| 163 |
+
prog = lesson.get("progress", 0)
|
| 164 |
+
st.progress(_progress_value(prog))
|
| 165 |
+
status = "✅ Complete" if (isinstance(prog, (int, float)) and prog >= 100) else f"{int(prog)}% complete"
|
| 166 |
+
st.write(f"**{lesson.get('title','Untitled Lesson')}** — {status}")
|
| 167 |
+
|
| 168 |
+
with col2:
|
| 169 |
+
st.subheader("🏆 Achievements")
|
| 170 |
+
st.caption("Your learning milestones")
|
| 171 |
+
for ach in achievements:
|
| 172 |
+
if ach["earned"]:
|
| 173 |
+
st.success(f"✔ {ach['title']} — {ach['desc']}")
|
| 174 |
+
else:
|
| 175 |
+
st.info(f"🔒 {ach['title']} — {ach['desc']}")
|
dashboards/teacher_db.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# dashboards/teacher_db.py
|
| 2 |
+
import streamlit as st
|
| 3 |
+
import os
|
| 4 |
+
import plotly.express as px
|
| 5 |
+
import plotly.graph_objects as go
|
| 6 |
+
import csv
|
| 7 |
+
import io
|
| 8 |
+
import datetime
|
| 9 |
+
from utils import db as dbapi
|
| 10 |
+
|
| 11 |
+
def load_css(file_name):
|
| 12 |
+
try:
|
| 13 |
+
with open(file_name, 'r') as f:
|
| 14 |
+
st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
|
| 15 |
+
except FileNotFoundError:
|
| 16 |
+
st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
|
| 17 |
+
|
| 18 |
+
def tile(icon, label, value):
|
| 19 |
+
return f"""
|
| 20 |
+
<div class="metric-card">
|
| 21 |
+
<div class="metric-icon">{icon}</div>
|
| 22 |
+
<div class="metric-value">{value}</div>
|
| 23 |
+
<div class="metric-label">{label}</div>
|
| 24 |
+
</div>
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def show_teacher_dashboard():
|
| 28 |
+
css_path = os.path.join("assets", "styles.css")
|
| 29 |
+
load_css(css_path)
|
| 30 |
+
|
| 31 |
+
user = st.session_state.user
|
| 32 |
+
teacher_id = user["user_id"]
|
| 33 |
+
name = user["name"]
|
| 34 |
+
|
| 35 |
+
# ========== HEADER / HERO ==========
|
| 36 |
+
colH1, colH2 = st.columns([5, 2])
|
| 37 |
+
with colH1:
|
| 38 |
+
st.markdown(f"""
|
| 39 |
+
<div class="header-container">
|
| 40 |
+
<div class="header-content">
|
| 41 |
+
<div class="header-left">
|
| 42 |
+
<div class="header-title">Welcome back, Teacher {name}!</div>
|
| 43 |
+
<div class="header-subtitle">Managing your classrooms</div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
</div>
|
| 47 |
+
""", unsafe_allow_html=True)
|
| 48 |
+
with colH2:
|
| 49 |
+
with st.popover("➕ Create Classroom"):
|
| 50 |
+
new_class_name = st.text_input("Classroom Name", key="new_class_name")
|
| 51 |
+
if st.button("Create Classroom", key="create_classroom_btn"):
|
| 52 |
+
if new_class_name.strip():
|
| 53 |
+
out = dbapi.create_class(teacher_id, new_class_name.strip())
|
| 54 |
+
st.success(f"Classroom created. Code: **{out['code']}**")
|
| 55 |
+
|
| 56 |
+
# ========== TILES ==========
|
| 57 |
+
tiles = dbapi.teacher_tiles(teacher_id)
|
| 58 |
+
c1,c2,c3,c4 = st.columns(4)
|
| 59 |
+
c1.markdown(tile("👥","Total Students", tiles["total_students"]), unsafe_allow_html=True)
|
| 60 |
+
c2.markdown(tile("📊","Class Average", f"{int(tiles['class_avg']*100)}%"), unsafe_allow_html=True)
|
| 61 |
+
c3.markdown(tile("📚","Lessons Created", tiles["lessons_created"]), unsafe_allow_html=True)
|
| 62 |
+
c4.markdown(tile("📈","Active Students", tiles["active_students"]), unsafe_allow_html=True)
|
| 63 |
+
|
| 64 |
+
# ========== CLASS PICKER ==========
|
| 65 |
+
classes = dbapi.list_classes_by_teacher(teacher_id)
|
| 66 |
+
if not classes:
|
| 67 |
+
st.info("No classes yet. Create one above, then share the code with students.")
|
| 68 |
+
return
|
| 69 |
+
|
| 70 |
+
idx = st.selectbox(
|
| 71 |
+
"Choose a class",
|
| 72 |
+
list(range(len(classes))),
|
| 73 |
+
index=0,
|
| 74 |
+
format_func=lambda i: f"{classes[i]['name']} (Code: {classes[i].get('code','')})"
|
| 75 |
+
)
|
| 76 |
+
selected = classes[idx]
|
| 77 |
+
class_id = selected["class_id"]
|
| 78 |
+
class_code = selected.get("code","")
|
| 79 |
+
|
| 80 |
+
# secondary hero controls
|
| 81 |
+
cTop1, cTop2, cTop3 = st.columns([2,1,1])
|
| 82 |
+
with cTop1:
|
| 83 |
+
st.button(f"Class Code: {class_code}", disabled=True)
|
| 84 |
+
with cTop2:
|
| 85 |
+
if st.button("📋 Copy Code"):
|
| 86 |
+
st.toast("Code copied. Paste it anywhere your heart desires.")
|
| 87 |
+
with cTop3:
|
| 88 |
+
rows = dbapi.class_student_metrics(class_id)
|
| 89 |
+
if rows:
|
| 90 |
+
headers = []
|
| 91 |
+
for r in rows:
|
| 92 |
+
for k in r.keys():
|
| 93 |
+
if k not in headers:
|
| 94 |
+
headers.append(k)
|
| 95 |
+
buf = io.StringIO()
|
| 96 |
+
writer = csv.DictWriter(buf, fieldnames=headers)
|
| 97 |
+
writer.writeheader()
|
| 98 |
+
for r in rows:
|
| 99 |
+
writer.writerow(r)
|
| 100 |
+
st.download_button(
|
| 101 |
+
"📤 Export Class Report",
|
| 102 |
+
data=buf.getvalue(),
|
| 103 |
+
file_name=f"class_{class_id}_report.csv",
|
| 104 |
+
mime="text/csv"
|
| 105 |
+
)
|
| 106 |
+
else:
|
| 107 |
+
st.button("📤 Export Class Report", disabled=True)
|
| 108 |
+
|
| 109 |
+
# ========== TOP ROW: WEEKLY ACTIVITY + CLASS PROGRESS ==========
|
| 110 |
+
left, right = st.columns([3,2])
|
| 111 |
+
|
| 112 |
+
with left:
|
| 113 |
+
st.subheader("Weekly Activity")
|
| 114 |
+
st.caption("Student engagement throughout the week")
|
| 115 |
+
activity = dbapi.class_weekly_activity(class_id)
|
| 116 |
+
if activity:
|
| 117 |
+
days = []
|
| 118 |
+
lessons, quizzes, games = [], [], []
|
| 119 |
+
for row in activity:
|
| 120 |
+
date_str = row.get("date")
|
| 121 |
+
try:
|
| 122 |
+
day = datetime.datetime.fromisoformat(date_str).strftime("%a")
|
| 123 |
+
except Exception:
|
| 124 |
+
day = str(date_str)
|
| 125 |
+
days.append(day)
|
| 126 |
+
lessons.append(row.get("lessons",0))
|
| 127 |
+
quizzes.append(row.get("quizzes",0))
|
| 128 |
+
games.append(row.get("games",0))
|
| 129 |
+
fig = go.Figure(data=[
|
| 130 |
+
go.Bar(name="Lessons", x=days, y=lessons),
|
| 131 |
+
go.Bar(name="Quizzes", x=days, y=quizzes),
|
| 132 |
+
go.Bar(name="Games", x=days, y=games),
|
| 133 |
+
])
|
| 134 |
+
fig.update_layout(barmode="group",
|
| 135 |
+
xaxis_title="Day",
|
| 136 |
+
yaxis_title="Count")
|
| 137 |
+
st.plotly_chart(fig, use_container_width=True)
|
| 138 |
+
else:
|
| 139 |
+
st.info("No activity in the last 7 days.")
|
| 140 |
+
|
| 141 |
+
with right:
|
| 142 |
+
st.subheader("Class Progress Overview")
|
| 143 |
+
st.caption("How your students are performing")
|
| 144 |
+
|
| 145 |
+
prog = dbapi.class_progress_overview(class_id)
|
| 146 |
+
overall_pct = int(round((prog["overall_progress"] or 0) * 100))
|
| 147 |
+
quiz_pct = int(round((prog["quiz_performance"] or 0) * 100))
|
| 148 |
+
|
| 149 |
+
st.text("Overall Progress")
|
| 150 |
+
st.progress(min(1.0, overall_pct/100.0))
|
| 151 |
+
st.caption(f"{overall_pct}%")
|
| 152 |
+
|
| 153 |
+
st.text("Quiz Performance")
|
| 154 |
+
st.progress(min(1.0, quiz_pct/100.0))
|
| 155 |
+
st.caption(f"{quiz_pct}%")
|
| 156 |
+
|
| 157 |
+
k1, k2 = st.columns(2)
|
| 158 |
+
k1.metric("📖 Lessons Completed", prog["lessons_completed"])
|
| 159 |
+
k2.metric("🪙 Total Class XP", prog["class_xp"])
|
| 160 |
+
|
| 161 |
+
# ========== BOTTOM ROW: RECENT ACTIVITY + QUICK ACTIONS ==========
|
| 162 |
+
b1, b2 = st.columns([3,2])
|
| 163 |
+
|
| 164 |
+
with b1:
|
| 165 |
+
st.subheader("Recent Student Activity")
|
| 166 |
+
st.caption("Latest activity from your students")
|
| 167 |
+
feed = dbapi.class_recent_activity(class_id, limit=6, days=30)
|
| 168 |
+
if not feed:
|
| 169 |
+
st.caption("Nothing yet. Assign something, chief.")
|
| 170 |
+
else:
|
| 171 |
+
for r in feed:
|
| 172 |
+
icon = "📘" if r["kind"] == "lesson" else "🏆" if r["kind"] == "quiz" else "🎮"
|
| 173 |
+
lvl = dbapi.level_from_xp(r.get("total_xp", 0))
|
| 174 |
+
tail = f" · {r['extra']}" if r.get("extra") else ""
|
| 175 |
+
st.write(f"{icon} **{r['student_name']}** — {r['item_title']}{tail} \n"
|
| 176 |
+
f"*Level {lvl}*")
|
| 177 |
+
|
| 178 |
+
with b2:
|
| 179 |
+
st.subheader("Quick Actions")
|
| 180 |
+
st.caption("Manage your classroom")
|
| 181 |
+
if st.button("📖 Create New Lesson", use_container_width=True):
|
| 182 |
+
st.session_state.current_page = "Content Management"
|
| 183 |
+
st.rerun()
|
| 184 |
+
if st.button("🏆 Create New Quiz", use_container_width=True):
|
| 185 |
+
st.session_state.current_page = "Content Management"
|
| 186 |
+
st.rerun()
|
| 187 |
+
if st.button("🗓️ Schedule Assignment", use_container_width=True):
|
| 188 |
+
st.session_state.current_page = "Class management"
|
| 189 |
+
st.rerun()
|
| 190 |
+
if st.button("📄 Generate Reports", use_container_width=True):
|
| 191 |
+
st.session_state.current_page = "Students List"
|
| 192 |
+
st.rerun()
|
| 193 |
+
|
| 194 |
+
# optional: keep your per-class expanders below
|
| 195 |
+
for c in classes:
|
| 196 |
+
with st.expander(f"{c['name']} · Code **{c.get('code','')}**"):
|
| 197 |
+
st.write(f"Students: {c['total_students']}")
|
| 198 |
+
st.write(f"Average score: {round(c['class_avg']*100)}%")
|
| 199 |
+
roster = dbapi.list_students_in_class(c["class_id"])
|
| 200 |
+
if roster:
|
| 201 |
+
for s in roster:
|
| 202 |
+
st.write(f"- {s['name']} · {s['email']} · Level {s['level_slug'].capitalize()}")
|
| 203 |
+
else:
|
| 204 |
+
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-----
|
tools/__init__.py
ADDED
|
File without changes
|
utils/db.py
ADDED
|
@@ -0,0 +1,1266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# utils/db.py (top of file)
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import certifi
|
| 5 |
+
import mysql.connector
|
| 6 |
+
from mysql.connector import Error
|
| 7 |
+
from contextlib import contextmanager
|
| 8 |
+
from datetime import date, timedelta
|
| 9 |
+
|
| 10 |
+
# password hashing
|
| 11 |
+
import bcrypt
|
| 12 |
+
|
| 13 |
+
# ----------- label <-> slug mappers for UI selects -----------
|
| 14 |
+
COUNTRY_SLUG = {
|
| 15 |
+
"Jamaica": "jamaica", "USA": "usa", "UK": "uk",
|
| 16 |
+
"India": "india", "Canada": "canada", "Other": "other", "N/A": "na"
|
| 17 |
+
}
|
| 18 |
+
LEVEL_SLUG = {
|
| 19 |
+
"Beginner": "beginner", "Intermediate": "intermediate", "Advanced": "advanced", "N/A": "na"
|
| 20 |
+
}
|
| 21 |
+
ROLE_SLUG = {"Student": "student", "Teacher": "teacher"}
|
| 22 |
+
|
| 23 |
+
def _slug(s: str) -> str:
|
| 24 |
+
return (s or "").strip().lower()
|
| 25 |
+
|
| 26 |
+
def hash_password(plain: str) -> bytes:
|
| 27 |
+
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt())
|
| 28 |
+
|
| 29 |
+
def verify_password(plain: str, hashed: bytes | None) -> bool:
|
| 30 |
+
if not plain or not hashed:
|
| 31 |
+
return False
|
| 32 |
+
try:
|
| 33 |
+
return bcrypt.checkpw(plain.encode("utf-8"), hashed)
|
| 34 |
+
except Exception:
|
| 35 |
+
return False
|
| 36 |
+
|
| 37 |
+
def _ensure_na_slugs():
|
| 38 |
+
"""
|
| 39 |
+
Make sure 'na' exists in countries/levels for teacher rows.
|
| 40 |
+
Harmless if already present.
|
| 41 |
+
"""
|
| 42 |
+
with cursor() as cur:
|
| 43 |
+
cur.execute("INSERT IGNORE INTO countries(slug,label) VALUES('na','N/A')")
|
| 44 |
+
cur.execute("INSERT IGNORE INTO levels(slug,label) VALUES('na','N/A')")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_db_connection():
|
| 48 |
+
ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
|
| 49 |
+
ssl_ca = certifi.where() if ssl_enabled else None
|
| 50 |
+
return mysql.connector.connect(
|
| 51 |
+
host=os.getenv("TIDB_HOST"),
|
| 52 |
+
port=int(os.getenv("TIDB_PORT", 4000)),
|
| 53 |
+
user=os.getenv("TIDB_USER"),
|
| 54 |
+
password=os.getenv("TIDB_PASSWORD"),
|
| 55 |
+
database=os.getenv("TIDB_DATABASE", "agenticfinance"),
|
| 56 |
+
ssl_ca=ssl_ca,
|
| 57 |
+
ssl_verify_cert=ssl_enabled,
|
| 58 |
+
autocommit=True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
@contextmanager
|
| 62 |
+
def cursor(dict_rows=True):
|
| 63 |
+
conn = get_db_connection()
|
| 64 |
+
try:
|
| 65 |
+
cur = conn.cursor(dictionary=dict_rows)
|
| 66 |
+
yield cur
|
| 67 |
+
conn.commit()
|
| 68 |
+
finally:
|
| 69 |
+
cur.close()
|
| 70 |
+
conn.close()
|
| 71 |
+
|
| 72 |
+
# ---------- USERS ----------
|
| 73 |
+
def create_user(name:str, email:str, country:str, level:str, role:str):
|
| 74 |
+
|
| 75 |
+
slug = lambda s: s.strip().lower()
|
| 76 |
+
with cursor() as cur:
|
| 77 |
+
cur.execute("""
|
| 78 |
+
INSERT INTO users(name,email,country_slug,level_slug,role_slug)
|
| 79 |
+
VALUES (%s,%s,%s,%s,%s)
|
| 80 |
+
""", (name, email.strip().lower(), slug(country), slug(level), slug(role)))
|
| 81 |
+
return True
|
| 82 |
+
|
| 83 |
+
# role-specific creators
|
| 84 |
+
def create_student(*, name:str, email:str, password:str, level_label:str, country_label:str) -> bool:
|
| 85 |
+
"""
|
| 86 |
+
level_label/country_label are UI labels (e.g., 'Beginner', 'Jamaica').
|
| 87 |
+
"""
|
| 88 |
+
level_slug = LEVEL_SLUG.get(level_label, _slug(level_label))
|
| 89 |
+
country_slug = COUNTRY_SLUG.get(country_label, _slug(country_label))
|
| 90 |
+
with cursor() as cur:
|
| 91 |
+
cur.execute("""
|
| 92 |
+
INSERT INTO users (name,email,password_hash,title,country_slug,level_slug,role_slug)
|
| 93 |
+
VALUES (%s,%s,%s,NULL,%s,%s,'student')
|
| 94 |
+
""", (name.strip(), email.strip().lower(), hash_password(password), country_slug, level_slug))
|
| 95 |
+
return True
|
| 96 |
+
|
| 97 |
+
def create_teacher(*, title:str, name:str, email:str, password:str) -> bool:
|
| 98 |
+
"""
|
| 99 |
+
Teachers do not provide level/country; we store 'na' for both.
|
| 100 |
+
"""
|
| 101 |
+
_ensure_na_slugs()
|
| 102 |
+
with cursor() as cur:
|
| 103 |
+
cur.execute("""
|
| 104 |
+
INSERT INTO users (title,name,email,password_hash,country_slug,level_slug,role_slug)
|
| 105 |
+
VALUES (%s,%s,%s,%s,'na','na','teacher')
|
| 106 |
+
""", (title.strip(), name.strip(), email.strip().lower(), hash_password(password)))
|
| 107 |
+
return True
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def get_user_by_email(email:str):
|
| 111 |
+
with cursor() as cur:
|
| 112 |
+
cur.execute("""
|
| 113 |
+
SELECT
|
| 114 |
+
u.user_id, u.title, u.name, u.email, u.password_hash,
|
| 115 |
+
u.country_slug, c.label AS country,
|
| 116 |
+
u.level_slug, l.label AS level,
|
| 117 |
+
u.role_slug, r.label AS role
|
| 118 |
+
FROM users u
|
| 119 |
+
JOIN countries c ON c.slug = u.country_slug
|
| 120 |
+
JOIN levels l ON l.slug = u.level_slug
|
| 121 |
+
JOIN roles r ON r.slug = u.role_slug
|
| 122 |
+
WHERE u.email=%s
|
| 123 |
+
LIMIT 1
|
| 124 |
+
""", (email.strip().lower(),))
|
| 125 |
+
u = cur.fetchone()
|
| 126 |
+
if not u:
|
| 127 |
+
return None
|
| 128 |
+
|
| 129 |
+
u["role"] = "Teacher" if u["role_slug"] == "teacher" else "Student"
|
| 130 |
+
return u
|
| 131 |
+
|
| 132 |
+
def check_password(email: str, plain_password: str) -> dict | None:
|
| 133 |
+
"""
|
| 134 |
+
Returns the user dict if password is correct, else None.
|
| 135 |
+
"""
|
| 136 |
+
user = get_user_by_email(email)
|
| 137 |
+
if not user:
|
| 138 |
+
return None
|
| 139 |
+
if verify_password(plain_password, user.get("password_hash")):
|
| 140 |
+
return user
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
# ---------- CLASSES ----------
|
| 145 |
+
import random, string
|
| 146 |
+
def _code():
|
| 147 |
+
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
| 148 |
+
|
| 149 |
+
def create_class(teacher_id:int, name:str):
|
| 150 |
+
# ensure unique code
|
| 151 |
+
for _ in range(20):
|
| 152 |
+
code = _code()
|
| 153 |
+
with cursor() as cur:
|
| 154 |
+
cur.execute("SELECT 1 FROM classes WHERE code=%s", (code,))
|
| 155 |
+
if not cur.fetchone():
|
| 156 |
+
cur.execute("INSERT INTO classes(teacher_id,name,code) VALUES(%s,%s,%s)",
|
| 157 |
+
(teacher_id, name, code))
|
| 158 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 159 |
+
cid = cur.fetchone()["id"]
|
| 160 |
+
return {"class_id": cid, "code": code}
|
| 161 |
+
raise RuntimeError("Could not generate unique class code")
|
| 162 |
+
|
| 163 |
+
def list_classes_by_teacher(teacher_id:int):
|
| 164 |
+
with cursor() as cur:
|
| 165 |
+
cur.execute("SELECT * FROM v_class_stats WHERE teacher_id=%s", (teacher_id,))
|
| 166 |
+
return cur.fetchall()
|
| 167 |
+
|
| 168 |
+
def join_class_by_code(student_id:int, code:str):
|
| 169 |
+
with cursor() as cur:
|
| 170 |
+
cur.execute("SELECT class_id FROM classes WHERE code=%s", (code.strip().upper(),))
|
| 171 |
+
row = cur.fetchone()
|
| 172 |
+
if not row:
|
| 173 |
+
raise ValueError("Invalid class code")
|
| 174 |
+
cur.execute("INSERT IGNORE INTO class_students(class_id,student_id) VALUES(%s,%s)",
|
| 175 |
+
(row["class_id"], student_id))
|
| 176 |
+
return row["class_id"]
|
| 177 |
+
|
| 178 |
+
def list_students_in_class(class_id:int):
|
| 179 |
+
with cursor() as cur:
|
| 180 |
+
cur.execute("""
|
| 181 |
+
SELECT
|
| 182 |
+
u.user_id, u.name, u.email, u.level_slug,
|
| 183 |
+
cs.joined_at, -- <- show true join date
|
| 184 |
+
u.created_at
|
| 185 |
+
FROM class_students cs
|
| 186 |
+
JOIN users u ON u.user_id = cs.student_id
|
| 187 |
+
WHERE cs.class_id = %s
|
| 188 |
+
ORDER BY u.name
|
| 189 |
+
""", (class_id,))
|
| 190 |
+
return cur.fetchall()
|
| 191 |
+
|
| 192 |
+
def class_analytics(class_id:int):
|
| 193 |
+
"""
|
| 194 |
+
Returns:
|
| 195 |
+
class_avg -> 0..1 average quiz score for the class (from v_class_stats)
|
| 196 |
+
total_xp -> sum of xp_log.delta for students in this class
|
| 197 |
+
lessons_completed -> count of completed lesson_progress entries for lessons assigned to this class
|
| 198 |
+
"""
|
| 199 |
+
out = {"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0}
|
| 200 |
+
|
| 201 |
+
with cursor() as cur:
|
| 202 |
+
# class average from view
|
| 203 |
+
cur.execute("SELECT class_avg FROM v_class_stats WHERE class_id=%s", (class_id,))
|
| 204 |
+
row = cur.fetchone()
|
| 205 |
+
if row:
|
| 206 |
+
out["class_avg"] = float(row["class_avg"] or 0)
|
| 207 |
+
|
| 208 |
+
# total XP for all students in this class
|
| 209 |
+
cur.execute("""
|
| 210 |
+
SELECT COALESCE(SUM(x.delta),0) AS total_xp
|
| 211 |
+
FROM xp_log x
|
| 212 |
+
JOIN class_students cs ON cs.student_id = x.user_id
|
| 213 |
+
WHERE cs.class_id = %s
|
| 214 |
+
""", (class_id,))
|
| 215 |
+
out["total_xp"] = int((cur.fetchone() or {"total_xp": 0})["total_xp"])
|
| 216 |
+
|
| 217 |
+
# lessons completed that were actually assigned to this class
|
| 218 |
+
cur.execute("""
|
| 219 |
+
SELECT COUNT(*) AS n
|
| 220 |
+
FROM lesson_progress lp
|
| 221 |
+
JOIN class_students cs ON cs.student_id = lp.user_id
|
| 222 |
+
JOIN assignments a ON a.lesson_id = lp.lesson_id
|
| 223 |
+
WHERE cs.class_id = %s
|
| 224 |
+
AND a.class_id = %s
|
| 225 |
+
AND lp.status = 'completed'
|
| 226 |
+
""", (class_id, class_id))
|
| 227 |
+
out["lessons_completed"] = int((cur.fetchone() or {"n": 0})["n"])
|
| 228 |
+
|
| 229 |
+
return out
|
| 230 |
+
|
| 231 |
+
# ---------- Teacher dash for real time data - Class Helpers ----------
|
| 232 |
+
def class_content_counts(class_id:int):
|
| 233 |
+
# counts of distinct lessons and quizzes assigned to this class
|
| 234 |
+
with cursor() as cur:
|
| 235 |
+
cur.execute("""
|
| 236 |
+
SELECT
|
| 237 |
+
COUNT(DISTINCT lesson_id) AS lessons,
|
| 238 |
+
COUNT(DISTINCT quiz_id) AS quizzes
|
| 239 |
+
FROM assignments
|
| 240 |
+
WHERE class_id=%s
|
| 241 |
+
""", (class_id,))
|
| 242 |
+
row = cur.fetchone() or {"lessons": 0, "quizzes": 0}
|
| 243 |
+
return row
|
| 244 |
+
|
| 245 |
+
def list_class_assignments(class_id:int):
|
| 246 |
+
with cursor() as cur:
|
| 247 |
+
cur.execute("""
|
| 248 |
+
SELECT
|
| 249 |
+
a.assignment_id,
|
| 250 |
+
a.created_at,
|
| 251 |
+
l.lesson_id, l.title, l.subject, l.level,
|
| 252 |
+
a.quiz_id
|
| 253 |
+
FROM assignments a
|
| 254 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 255 |
+
WHERE a.class_id=%s
|
| 256 |
+
ORDER BY a.created_at DESC
|
| 257 |
+
""", (class_id,))
|
| 258 |
+
return cur.fetchall()
|
| 259 |
+
|
| 260 |
+
def list_classes_by_teacher(teacher_id:int):
|
| 261 |
+
with cursor() as cur:
|
| 262 |
+
cur.execute("""
|
| 263 |
+
SELECT s.*, c.code
|
| 264 |
+
FROM v_class_stats s
|
| 265 |
+
JOIN classes c USING (class_id)
|
| 266 |
+
WHERE s.teacher_id=%s
|
| 267 |
+
ORDER BY c.created_at DESC
|
| 268 |
+
""", (teacher_id,))
|
| 269 |
+
return cur.fetchall()
|
| 270 |
+
|
| 271 |
+
def get_class(class_id:int):
|
| 272 |
+
with cursor() as cur:
|
| 273 |
+
cur.execute("SELECT class_id, name, code, teacher_id FROM classes WHERE class_id=%s", (class_id,))
|
| 274 |
+
return cur.fetchone()
|
| 275 |
+
|
| 276 |
+
def class_student_metrics(class_id: int):
|
| 277 |
+
"""
|
| 278 |
+
Returns one row per student in the class with:
|
| 279 |
+
name, email, joined_at, lessons_completed, total_assigned_lessons,
|
| 280 |
+
avg_score (0..1), streak_days, total_xp
|
| 281 |
+
"""
|
| 282 |
+
with cursor() as cur:
|
| 283 |
+
cur.execute("""
|
| 284 |
+
/* total assigned lessons for the class */
|
| 285 |
+
WITH assigned AS (
|
| 286 |
+
SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
|
| 287 |
+
)
|
| 288 |
+
SELECT
|
| 289 |
+
cs.student_id,
|
| 290 |
+
u.name,
|
| 291 |
+
u.email,
|
| 292 |
+
cs.joined_at,
|
| 293 |
+
/* lessons completed by this student that were assigned to this class */
|
| 294 |
+
COALESCE(
|
| 295 |
+
(SELECT COUNT(*) FROM lesson_progress lp
|
| 296 |
+
WHERE lp.user_id = cs.student_id
|
| 297 |
+
AND lp.status = 'completed'
|
| 298 |
+
AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
|
| 299 |
+
), 0
|
| 300 |
+
) AS lessons_completed,
|
| 301 |
+
/* total lessons assigned to this class */
|
| 302 |
+
(SELECT COUNT(*) FROM assigned) AS total_assigned_lessons,
|
| 303 |
+
/* average quiz score only for submissions tied to this class */
|
| 304 |
+
COALESCE(sc.avg_score, 0) AS avg_score,
|
| 305 |
+
/* streak days from streaks table */
|
| 306 |
+
COALESCE(str.days, 0) AS streak_days,
|
| 307 |
+
/* total XP across the app */
|
| 308 |
+
COALESCE(xp.total_xp, 0) AS total_xp
|
| 309 |
+
FROM class_students cs
|
| 310 |
+
JOIN users u ON u.user_id = cs.student_id
|
| 311 |
+
LEFT JOIN (
|
| 312 |
+
SELECT s.student_id, AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_score
|
| 313 |
+
FROM submissions s
|
| 314 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 315 |
+
WHERE a.class_id = %s
|
| 316 |
+
GROUP BY s.student_id
|
| 317 |
+
) sc ON sc.student_id = cs.student_id
|
| 318 |
+
LEFT JOIN streaks str ON str.user_id = cs.student_id
|
| 319 |
+
LEFT JOIN (SELECT user_id, SUM(delta) AS total_xp FROM xp_log GROUP BY user_id) xp
|
| 320 |
+
ON xp.user_id = cs.student_id
|
| 321 |
+
WHERE cs.class_id = %s
|
| 322 |
+
ORDER BY u.name;
|
| 323 |
+
""", (class_id, class_id, class_id))
|
| 324 |
+
return cur.fetchall()
|
| 325 |
+
|
| 326 |
+
def level_from_xp(total_xp: int) -> int:
|
| 327 |
+
try:
|
| 328 |
+
xp = int(total_xp or 0)
|
| 329 |
+
except Exception:
|
| 330 |
+
xp = 0
|
| 331 |
+
return 1 + xp // 500
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def list_classes_for_student(student_id: int):
|
| 335 |
+
with cursor() as cur:
|
| 336 |
+
cur.execute("""
|
| 337 |
+
SELECT c.class_id, c.name, c.code, c.teacher_id,
|
| 338 |
+
t.name AS teacher_name, cs.joined_at
|
| 339 |
+
FROM class_students cs
|
| 340 |
+
JOIN classes c ON c.class_id = cs.class_id
|
| 341 |
+
JOIN users t ON t.user_id = c.teacher_id
|
| 342 |
+
WHERE cs.student_id = %s
|
| 343 |
+
ORDER BY cs.joined_at DESC
|
| 344 |
+
""", (student_id,))
|
| 345 |
+
return cur.fetchall()
|
| 346 |
+
|
| 347 |
+
def leave_class(student_id: int, class_id: int):
|
| 348 |
+
with cursor() as cur:
|
| 349 |
+
cur.execute("DELETE FROM class_students WHERE student_id=%s AND class_id=%s",
|
| 350 |
+
(student_id, class_id))
|
| 351 |
+
return True
|
| 352 |
+
|
| 353 |
+
def student_class_progress(student_id: int, class_id: int):
|
| 354 |
+
"""
|
| 355 |
+
Per-student view of progress inside ONE class.
|
| 356 |
+
Returns: dict(overall_progress 0..1, lessons_completed int,
|
| 357 |
+
total_assigned_lessons int, avg_score 0..1)
|
| 358 |
+
"""
|
| 359 |
+
with cursor() as cur:
|
| 360 |
+
# total distinct lessons assigned to this class
|
| 361 |
+
cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s",
|
| 362 |
+
(class_id,))
|
| 363 |
+
total_assigned = int((cur.fetchone() or {"n": 0})["n"])
|
| 364 |
+
|
| 365 |
+
# lessons completed among the class's assigned lessons
|
| 366 |
+
cur.execute("""
|
| 367 |
+
WITH assigned AS (SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s)
|
| 368 |
+
SELECT COUNT(*) AS n
|
| 369 |
+
FROM lesson_progress lp
|
| 370 |
+
WHERE lp.user_id = %s
|
| 371 |
+
AND lp.status = 'completed'
|
| 372 |
+
AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
|
| 373 |
+
""", (class_id, student_id))
|
| 374 |
+
completed = int((cur.fetchone() or {"n": 0})["n"])
|
| 375 |
+
|
| 376 |
+
# student’s avg quiz score but only for submissions tied to this class
|
| 377 |
+
cur.execute("""
|
| 378 |
+
SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
|
| 379 |
+
FROM submissions s
|
| 380 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 381 |
+
WHERE a.class_id = %s AND s.student_id = %s
|
| 382 |
+
""", (class_id, student_id))
|
| 383 |
+
avg_score = float((cur.fetchone() or {"avg_ratio": 0.0})["avg_ratio"] or 0.0)
|
| 384 |
+
|
| 385 |
+
overall = (completed / float(total_assigned)) if total_assigned else 0.0
|
| 386 |
+
return dict(
|
| 387 |
+
overall_progress=overall,
|
| 388 |
+
lessons_completed=completed,
|
| 389 |
+
total_assigned_lessons=total_assigned,
|
| 390 |
+
avg_score=avg_score
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
def student_assignments_for_class(student_id: int, class_id: int):
|
| 394 |
+
"""
|
| 395 |
+
All assignments in a class, annotated with THIS student's status/progress
|
| 396 |
+
and (if applicable) their quiz score for that assignment.
|
| 397 |
+
Deduplicates by lesson_id (keeps the most recent assignment per lesson).
|
| 398 |
+
"""
|
| 399 |
+
with cursor() as cur:
|
| 400 |
+
cur.execute("""
|
| 401 |
+
SELECT
|
| 402 |
+
a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
|
| 403 |
+
a.quiz_id, a.due_at,
|
| 404 |
+
COALESCE(lp.status,'not_started') AS status,
|
| 405 |
+
lp.current_pos,
|
| 406 |
+
/* student's latest submission on this assignment (if any) */
|
| 407 |
+
(SELECT MAX(s.submitted_at) FROM submissions s
|
| 408 |
+
WHERE s.assignment_id = a.assignment_id AND s.student_id = %s) AS last_submit_at,
|
| 409 |
+
(SELECT s2.score FROM submissions s2
|
| 410 |
+
WHERE s2.assignment_id = a.assignment_id AND s2.student_id = %s
|
| 411 |
+
ORDER BY s2.submitted_at DESC LIMIT 1) AS score,
|
| 412 |
+
(SELECT s3.total FROM submissions s3
|
| 413 |
+
WHERE s3.assignment_id = a.assignment_id AND s3.student_id = %s
|
| 414 |
+
ORDER BY s3.submitted_at DESC LIMIT 1) AS total
|
| 415 |
+
FROM (
|
| 416 |
+
SELECT
|
| 417 |
+
a.*,
|
| 418 |
+
ROW_NUMBER() OVER (
|
| 419 |
+
PARTITION BY a.lesson_id
|
| 420 |
+
ORDER BY a.created_at DESC, a.assignment_id DESC
|
| 421 |
+
) AS rn
|
| 422 |
+
FROM assignments a
|
| 423 |
+
WHERE a.class_id = %s
|
| 424 |
+
) AS a
|
| 425 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 426 |
+
LEFT JOIN lesson_progress lp
|
| 427 |
+
ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
|
| 428 |
+
WHERE a.rn = 1
|
| 429 |
+
ORDER BY a.created_at DESC
|
| 430 |
+
""", (student_id, student_id, student_id, class_id, student_id))
|
| 431 |
+
return cur.fetchall()
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def update_quiz(quiz_id:int, teacher_id:int, title:str, items:list[dict], settings:dict|None=None) -> bool:
|
| 437 |
+
with cursor() as cur:
|
| 438 |
+
# only the teacher who owns the linked lesson can edit
|
| 439 |
+
cur.execute("""
|
| 440 |
+
SELECT 1
|
| 441 |
+
FROM quizzes q
|
| 442 |
+
JOIN lessons l ON l.lesson_id = q.lesson_id
|
| 443 |
+
WHERE q.quiz_id = %s AND l.teacher_id = %s
|
| 444 |
+
LIMIT 1
|
| 445 |
+
""", (quiz_id, teacher_id))
|
| 446 |
+
if not cur.fetchone():
|
| 447 |
+
return False
|
| 448 |
+
|
| 449 |
+
cur.execute("UPDATE quizzes SET title=%s, settings=%s WHERE quiz_id=%s",
|
| 450 |
+
(title, json.dumps(settings or {}), quiz_id))
|
| 451 |
+
|
| 452 |
+
cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
|
| 453 |
+
for i, it in enumerate(items, start=1):
|
| 454 |
+
cur.execute("""
|
| 455 |
+
INSERT INTO quiz_items(quiz_id, position, question, options, answer_key, points)
|
| 456 |
+
VALUES (%s, %s, %s, %s, %s, %s)
|
| 457 |
+
""", (
|
| 458 |
+
quiz_id, i,
|
| 459 |
+
it["question"],
|
| 460 |
+
json.dumps(it.get("options", [])),
|
| 461 |
+
json.dumps(it.get("answer_key")), # single letter as JSON string
|
| 462 |
+
int(it.get("points", 1))
|
| 463 |
+
))
|
| 464 |
+
return True
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def class_weekly_activity(class_id:int):
|
| 468 |
+
start = date.today() - timedelta(days=6)
|
| 469 |
+
with cursor() as cur:
|
| 470 |
+
cur.execute("""
|
| 471 |
+
SELECT DATE(lp.last_accessed) d, COUNT(*) n
|
| 472 |
+
FROM lesson_progress lp
|
| 473 |
+
JOIN class_students cs ON cs.student_id = lp.user_id
|
| 474 |
+
WHERE cs.class_id=%s AND lp.last_accessed >= %s
|
| 475 |
+
GROUP BY DATE(lp.last_accessed)
|
| 476 |
+
""", (class_id, start))
|
| 477 |
+
lessons = {r["d"]: r["n"] for r in cur.fetchall()}
|
| 478 |
+
|
| 479 |
+
cur.execute("""
|
| 480 |
+
SELECT DATE(s.submitted_at) d, COUNT(*) n
|
| 481 |
+
FROM submissions s
|
| 482 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 483 |
+
WHERE a.class_id=%s AND s.submitted_at >= %s
|
| 484 |
+
GROUP BY DATE(s.submitted_at)
|
| 485 |
+
""", (class_id, start))
|
| 486 |
+
quizzes = {r["d"]: r["n"] for r in cur.fetchall()}
|
| 487 |
+
|
| 488 |
+
cur.execute("""
|
| 489 |
+
SELECT DATE(g.started_at) d, COUNT(*) n
|
| 490 |
+
FROM game_sessions g
|
| 491 |
+
JOIN class_students cs ON cs.student_id = g.user_id
|
| 492 |
+
WHERE cs.class_id=%s AND g.started_at >= %s
|
| 493 |
+
GROUP BY DATE(g.started_at)
|
| 494 |
+
""", (class_id, start))
|
| 495 |
+
games = {r["d"]: r["n"] for r in cur.fetchall()}
|
| 496 |
+
|
| 497 |
+
out = []
|
| 498 |
+
for i in range(7):
|
| 499 |
+
d = start + timedelta(days=i)
|
| 500 |
+
out.append({
|
| 501 |
+
"date": d,
|
| 502 |
+
"lessons": lessons.get(d, 0),
|
| 503 |
+
"quizzes": quizzes.get(d, 0),
|
| 504 |
+
"games": games.get(d, 0),
|
| 505 |
+
})
|
| 506 |
+
return out
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def update_lesson(lesson_id:int, teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]) -> bool:
|
| 512 |
+
with cursor() as cur:
|
| 513 |
+
# ownership check
|
| 514 |
+
cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
|
| 515 |
+
if not cur.fetchone():
|
| 516 |
+
return False
|
| 517 |
+
|
| 518 |
+
cur.execute("""
|
| 519 |
+
UPDATE lessons
|
| 520 |
+
SET title=%s, description=%s, subject=%s, level=%s
|
| 521 |
+
WHERE lesson_id=%s AND teacher_id=%s
|
| 522 |
+
""", (title, description, subject, level_slug, lesson_id, teacher_id))
|
| 523 |
+
|
| 524 |
+
# simplest and safest: rebuild sections in order
|
| 525 |
+
cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
|
| 526 |
+
for i, sec in enumerate(sections, start=1):
|
| 527 |
+
cur.execute("""
|
| 528 |
+
INSERT INTO lesson_sections(lesson_id,position,title,content)
|
| 529 |
+
VALUES(%s,%s,%s,%s)
|
| 530 |
+
""", (lesson_id, i, sec.get("title"), sec.get("content")))
|
| 531 |
+
return True
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
# --- Class progress overview (overall progress, quiz performance, totals)
|
| 535 |
+
def class_progress_overview(class_id: int):
|
| 536 |
+
"""
|
| 537 |
+
Returns:
|
| 538 |
+
{
|
| 539 |
+
"overall_progress": 0..1,
|
| 540 |
+
"quiz_performance": 0..1,
|
| 541 |
+
"lessons_completed": int,
|
| 542 |
+
"class_xp": int
|
| 543 |
+
}
|
| 544 |
+
"""
|
| 545 |
+
with cursor() as cur:
|
| 546 |
+
# total distinct lessons assigned to this class
|
| 547 |
+
cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s", (class_id,))
|
| 548 |
+
total_assigned = int((cur.fetchone() or {"n": 0})["n"])
|
| 549 |
+
|
| 550 |
+
# number of enrolled students
|
| 551 |
+
cur.execute("SELECT COUNT(*) AS n FROM class_students WHERE class_id=%s", (class_id,))
|
| 552 |
+
num_students = int((cur.fetchone() or {"n": 0})["n"])
|
| 553 |
+
|
| 554 |
+
# sum of completed lessons by all students (for assigned lessons)
|
| 555 |
+
cur.execute("""
|
| 556 |
+
WITH assigned AS (
|
| 557 |
+
SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
|
| 558 |
+
), enrolled AS (
|
| 559 |
+
SELECT student_id FROM class_students WHERE class_id = %s
|
| 560 |
+
), per_student AS (
|
| 561 |
+
SELECT e.student_id,
|
| 562 |
+
COUNT(DISTINCT CASE
|
| 563 |
+
WHEN lp.status='completed' AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
|
| 564 |
+
THEN lp.lesson_id END) AS completed
|
| 565 |
+
FROM enrolled e
|
| 566 |
+
LEFT JOIN lesson_progress lp ON lp.user_id = e.student_id
|
| 567 |
+
GROUP BY e.student_id
|
| 568 |
+
)
|
| 569 |
+
SELECT COALESCE(SUM(completed),0) AS total_completed
|
| 570 |
+
FROM per_student
|
| 571 |
+
""", (class_id, class_id))
|
| 572 |
+
total_completed = int((cur.fetchone() or {"total_completed": 0})["total_completed"] or 0)
|
| 573 |
+
|
| 574 |
+
# quiz performance: average percentage for submissions tied to this class
|
| 575 |
+
cur.execute("""
|
| 576 |
+
SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
|
| 577 |
+
FROM submissions s
|
| 578 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id
|
| 579 |
+
WHERE a.class_id = %s
|
| 580 |
+
""", (class_id,))
|
| 581 |
+
quiz_perf_row = cur.fetchone() or {"avg_ratio": 0}
|
| 582 |
+
quiz_perf = float(quiz_perf_row["avg_ratio"] or 0)
|
| 583 |
+
|
| 584 |
+
# total class XP (sum of xp for enrolled students)
|
| 585 |
+
cur.execute("""
|
| 586 |
+
SELECT COALESCE(SUM(x.delta),0) AS xp
|
| 587 |
+
FROM xp_log x
|
| 588 |
+
WHERE x.user_id IN (SELECT student_id FROM class_students WHERE class_id=%s)
|
| 589 |
+
""", (class_id,))
|
| 590 |
+
class_xp = int((cur.fetchone() or {"xp": 0})["xp"] or 0)
|
| 591 |
+
|
| 592 |
+
if total_assigned and num_students:
|
| 593 |
+
denominator = float(total_assigned * num_students)
|
| 594 |
+
overall = float(total_completed) / denominator
|
| 595 |
+
else:
|
| 596 |
+
overall = 0.0
|
| 597 |
+
|
| 598 |
+
return dict(
|
| 599 |
+
overall_progress=float(overall),
|
| 600 |
+
quiz_performance=float(quiz_perf),
|
| 601 |
+
lessons_completed=int(total_completed),
|
| 602 |
+
class_xp=int(class_xp),
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
# --- Recent student activity with total_xp for level badge
|
| 606 |
+
def class_recent_activity(class_id:int, limit:int=6, days:int=30):
|
| 607 |
+
"""
|
| 608 |
+
Returns latest activity rows with fields:
|
| 609 |
+
ts, kind('lesson'|'quiz'|'game'), student_id, student_name, item_title, extra, total_xp
|
| 610 |
+
"""
|
| 611 |
+
with cursor() as cur:
|
| 612 |
+
cur.execute(f"""
|
| 613 |
+
WITH enrolled AS (
|
| 614 |
+
SELECT student_id FROM class_students WHERE class_id = %s
|
| 615 |
+
),
|
| 616 |
+
xp AS (
|
| 617 |
+
SELECT user_id, COALESCE(SUM(delta),0) AS total_xp
|
| 618 |
+
FROM xp_log GROUP BY user_id
|
| 619 |
+
)
|
| 620 |
+
SELECT * FROM (
|
| 621 |
+
/* completed lessons */
|
| 622 |
+
SELECT lp.last_accessed AS ts,
|
| 623 |
+
'lesson' AS kind,
|
| 624 |
+
u.user_id AS student_id,
|
| 625 |
+
u.name AS student_name,
|
| 626 |
+
l.title AS item_title,
|
| 627 |
+
NULL AS extra,
|
| 628 |
+
COALESCE(xp.total_xp,0) AS total_xp
|
| 629 |
+
FROM lesson_progress lp
|
| 630 |
+
JOIN enrolled e ON e.student_id = lp.user_id
|
| 631 |
+
JOIN users u ON u.user_id = lp.user_id
|
| 632 |
+
JOIN lessons l ON l.lesson_id = lp.lesson_id
|
| 633 |
+
LEFT JOIN xp ON xp.user_id = u.user_id
|
| 634 |
+
WHERE lp.status = 'completed' AND lp.last_accessed >= NOW() - INTERVAL {days} DAY
|
| 635 |
+
|
| 636 |
+
UNION ALL
|
| 637 |
+
|
| 638 |
+
/* quiz submissions */
|
| 639 |
+
SELECT s.submitted_at AS ts,
|
| 640 |
+
'quiz' AS kind,
|
| 641 |
+
u.user_id AS student_id,
|
| 642 |
+
u.name AS student_name,
|
| 643 |
+
l.title AS item_title,
|
| 644 |
+
CONCAT(ROUND(s.score*100.0/NULLIF(s.total,0)),'%') AS extra,
|
| 645 |
+
COALESCE(xp.total_xp,0) AS total_xp
|
| 646 |
+
FROM submissions s
|
| 647 |
+
JOIN assignments a ON a.assignment_id = s.assignment_id AND a.class_id = %s
|
| 648 |
+
JOIN users u ON u.user_id = s.student_id
|
| 649 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 650 |
+
LEFT JOIN xp ON xp.user_id = u.user_id
|
| 651 |
+
WHERE s.submitted_at >= NOW() - INTERVAL {days} DAY
|
| 652 |
+
|
| 653 |
+
UNION ALL
|
| 654 |
+
|
| 655 |
+
/* games */
|
| 656 |
+
SELECT g.started_at AS ts,
|
| 657 |
+
'game' AS kind,
|
| 658 |
+
u.user_id AS student_id,
|
| 659 |
+
u.name AS student_name,
|
| 660 |
+
g.game_slug AS item_title,
|
| 661 |
+
NULL AS extra,
|
| 662 |
+
COALESCE(xp.total_xp,0) AS total_xp
|
| 663 |
+
FROM game_sessions g
|
| 664 |
+
JOIN enrolled e ON e.student_id = g.user_id
|
| 665 |
+
JOIN users u ON u.user_id = g.user_id
|
| 666 |
+
LEFT JOIN xp ON xp.user_id = u.user_id
|
| 667 |
+
WHERE g.started_at >= NOW() - INTERVAL {days} DAY
|
| 668 |
+
) x
|
| 669 |
+
ORDER BY ts DESC
|
| 670 |
+
LIMIT %s
|
| 671 |
+
""", (class_id, class_id, limit))
|
| 672 |
+
return cur.fetchall()
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def list_quizzes_by_teacher(teacher_id:int):
|
| 677 |
+
with cursor() as cur:
|
| 678 |
+
cur.execute("""
|
| 679 |
+
SELECT q.quiz_id, q.title, q.created_at,
|
| 680 |
+
l.title AS lesson_title,
|
| 681 |
+
(SELECT COUNT(*) FROM quiz_items qi WHERE qi.quiz_id=q.quiz_id) AS num_items
|
| 682 |
+
FROM quizzes q
|
| 683 |
+
JOIN lessons l ON l.lesson_id=q.lesson_id
|
| 684 |
+
WHERE l.teacher_id=%s
|
| 685 |
+
ORDER BY q.created_at DESC
|
| 686 |
+
""", (teacher_id,))
|
| 687 |
+
return cur.fetchall()
|
| 688 |
+
|
| 689 |
+
def list_all_students_for_teacher(teacher_id:int):
|
| 690 |
+
with cursor() as cur:
|
| 691 |
+
cur.execute("""
|
| 692 |
+
SELECT DISTINCT u.user_id, u.name, u.email
|
| 693 |
+
FROM classes c
|
| 694 |
+
JOIN class_students cs ON cs.class_id=c.class_id
|
| 695 |
+
JOIN users u ON u.user_id=cs.student_id
|
| 696 |
+
WHERE c.teacher_id=%s
|
| 697 |
+
ORDER BY u.name
|
| 698 |
+
""", (teacher_id,))
|
| 699 |
+
return cur.fetchall()
|
| 700 |
+
|
| 701 |
+
# ----- ASSIGNEES (students) -----
|
| 702 |
+
|
| 703 |
+
def list_assigned_students_for_lesson(lesson_id:int):
|
| 704 |
+
with cursor() as cur:
|
| 705 |
+
cur.execute("""
|
| 706 |
+
WITH direct AS (
|
| 707 |
+
SELECT student_id FROM assignments
|
| 708 |
+
WHERE lesson_id=%s AND student_id IS NOT NULL
|
| 709 |
+
),
|
| 710 |
+
via_class AS (
|
| 711 |
+
SELECT cs.student_id
|
| 712 |
+
FROM assignments a
|
| 713 |
+
JOIN class_students cs ON cs.class_id=a.class_id
|
| 714 |
+
WHERE a.lesson_id=%s AND a.class_id IS NOT NULL
|
| 715 |
+
),
|
| 716 |
+
all_students AS (
|
| 717 |
+
SELECT student_id FROM direct
|
| 718 |
+
UNION
|
| 719 |
+
SELECT student_id FROM via_class
|
| 720 |
+
)
|
| 721 |
+
SELECT u.user_id, u.name, u.email
|
| 722 |
+
FROM users u
|
| 723 |
+
JOIN all_students s ON s.student_id=u.user_id
|
| 724 |
+
ORDER BY u.name
|
| 725 |
+
""", (lesson_id, lesson_id))
|
| 726 |
+
return cur.fetchall()
|
| 727 |
+
|
| 728 |
+
def list_assigned_students_for_quiz(quiz_id:int):
|
| 729 |
+
with cursor() as cur:
|
| 730 |
+
cur.execute("""
|
| 731 |
+
WITH direct AS (
|
| 732 |
+
SELECT student_id FROM assignments
|
| 733 |
+
WHERE quiz_id=%s AND student_id IS NOT NULL
|
| 734 |
+
),
|
| 735 |
+
via_class AS (
|
| 736 |
+
SELECT cs.student_id
|
| 737 |
+
FROM assignments a
|
| 738 |
+
JOIN class_students cs ON cs.class_id=a.class_id
|
| 739 |
+
WHERE a.quiz_id=%s AND a.class_id IS NOT NULL
|
| 740 |
+
),
|
| 741 |
+
all_students AS (
|
| 742 |
+
SELECT student_id FROM direct
|
| 743 |
+
UNION
|
| 744 |
+
SELECT student_id FROM via_class
|
| 745 |
+
)
|
| 746 |
+
SELECT u.user_id, u.name, u.email
|
| 747 |
+
FROM users u
|
| 748 |
+
JOIN all_students s ON s.student_id=u.user_id
|
| 749 |
+
ORDER BY u.name
|
| 750 |
+
""", (quiz_id, quiz_id))
|
| 751 |
+
return cur.fetchall()
|
| 752 |
+
|
| 753 |
+
# ----- ASSIGN ACTIONS -----
|
| 754 |
+
|
| 755 |
+
def assign_lesson_to_students(lesson_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
|
| 756 |
+
# bulk insert; quiz_id stays NULL
|
| 757 |
+
with cursor() as cur:
|
| 758 |
+
for sid in student_ids:
|
| 759 |
+
cur.execute("""
|
| 760 |
+
INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
|
| 761 |
+
VALUES(%s, NULL, %s, %s, %s)
|
| 762 |
+
ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
|
| 763 |
+
""", (lesson_id, sid, teacher_id, due_at))
|
| 764 |
+
return True
|
| 765 |
+
|
| 766 |
+
def assign_quiz_to_students(quiz_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
|
| 767 |
+
# get lesson_id for integrity
|
| 768 |
+
with cursor() as cur:
|
| 769 |
+
cur.execute("SELECT lesson_id FROM quizzes WHERE quiz_id=%s", (quiz_id,))
|
| 770 |
+
row = cur.fetchone()
|
| 771 |
+
if not row:
|
| 772 |
+
raise ValueError("Quiz not found")
|
| 773 |
+
lesson_id = row["lesson_id"]
|
| 774 |
+
for sid in student_ids:
|
| 775 |
+
cur.execute("""
|
| 776 |
+
INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
|
| 777 |
+
VALUES(%s, %s, %s, %s, %s)
|
| 778 |
+
ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
|
| 779 |
+
""", (lesson_id, quiz_id, sid, teacher_id, due_at))
|
| 780 |
+
return True
|
| 781 |
+
|
| 782 |
+
# ----- SAFE DELETE -----
|
| 783 |
+
|
| 784 |
+
def delete_lesson(lesson_id:int, teacher_id:int):
|
| 785 |
+
with cursor() as cur:
|
| 786 |
+
# ownership check
|
| 787 |
+
cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
|
| 788 |
+
if not cur.fetchone():
|
| 789 |
+
return False, "You can only delete own lesson."
|
| 790 |
+
# block if assigned or quizzed
|
| 791 |
+
cur.execute("SELECT COUNT(*) AS n FROM assignments WHERE lesson_id=%s", (lesson_id,))
|
| 792 |
+
if cur.fetchone()["n"] > 0:
|
| 793 |
+
return False, "Remove assignments first."
|
| 794 |
+
cur.execute("SELECT COUNT(*) AS n FROM quizzes WHERE lesson_id=%s", (lesson_id,))
|
| 795 |
+
if cur.fetchone()["n"] > 0:
|
| 796 |
+
return False, "Delete quizzes for this lesson first."
|
| 797 |
+
# delete sections then lesson
|
| 798 |
+
cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
|
| 799 |
+
cur.execute("DELETE FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
|
| 800 |
+
return True, "Deleted."
|
| 801 |
+
|
| 802 |
+
def delete_quiz(quiz_id:int, teacher_id:int):
|
| 803 |
+
with cursor() as cur:
|
| 804 |
+
cur.execute("""
|
| 805 |
+
SELECT 1
|
| 806 |
+
FROM quizzes q JOIN lessons l ON l.lesson_id=q.lesson_id
|
| 807 |
+
WHERE q.quiz_id=%s AND l.teacher_id=%s
|
| 808 |
+
""", (quiz_id, teacher_id))
|
| 809 |
+
if not cur.fetchone():
|
| 810 |
+
return False, "You can only delete own quiz."
|
| 811 |
+
cur.execute("SELECT COUNT(*) AS n FROM submissions WHERE quiz_id=%s", (quiz_id,))
|
| 812 |
+
if cur.fetchone()["n"] > 0:
|
| 813 |
+
return False, "This quiz has submissions. Deleting is blocked."
|
| 814 |
+
cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
|
| 815 |
+
cur.execute("DELETE FROM assignments WHERE quiz_id=%s", (quiz_id,))
|
| 816 |
+
cur.execute("DELETE FROM quizzes WHERE quiz_id=%s", (quiz_id,))
|
| 817 |
+
return True, "Deleted."
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
def _bump_game_stats(user_id:int, slug:str, *, gained_xp:int, matched:int|None=None, level_inc:int=0):
|
| 821 |
+
with cursor() as cur:
|
| 822 |
+
cur.execute("""
|
| 823 |
+
INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
|
| 824 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 825 |
+
ON DUPLICATE KEY UPDATE
|
| 826 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 827 |
+
matches = matches + VALUES(matches),
|
| 828 |
+
level = GREATEST(level, VALUES(level))
|
| 829 |
+
""", (user_id, slug, int(gained_xp), int(matched or 1), level_inc))
|
| 830 |
+
|
| 831 |
+
# ---------- LESSONS ----------
|
| 832 |
+
def create_lesson(teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]):
|
| 833 |
+
with cursor() as cur:
|
| 834 |
+
cur.execute("""
|
| 835 |
+
INSERT INTO lessons(teacher_id,title,description,subject,level,duration_min)
|
| 836 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 837 |
+
""", (teacher_id, title, description, subject, level_slug, 60))
|
| 838 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 839 |
+
lesson_id = cur.fetchone()["id"]
|
| 840 |
+
for i, sec in enumerate(sections, start=1):
|
| 841 |
+
cur.execute("""
|
| 842 |
+
INSERT INTO lesson_sections(lesson_id,position,title,content)
|
| 843 |
+
VALUES(%s,%s,%s,%s)
|
| 844 |
+
""", (lesson_id, i, sec.get("title"), sec.get("content")))
|
| 845 |
+
return lesson_id
|
| 846 |
+
|
| 847 |
+
def list_lessons_by_teacher(teacher_id:int):
|
| 848 |
+
with cursor() as cur:
|
| 849 |
+
cur.execute("SELECT * FROM lessons WHERE teacher_id=%s ORDER BY created_at DESC", (teacher_id,))
|
| 850 |
+
return cur.fetchall()
|
| 851 |
+
|
| 852 |
+
def get_lesson(lesson_id:int):
|
| 853 |
+
with cursor() as cur:
|
| 854 |
+
cur.execute("SELECT * FROM lessons WHERE lesson_id=%s", (lesson_id,))
|
| 855 |
+
lesson = cur.fetchone()
|
| 856 |
+
cur.execute("SELECT * FROM lesson_sections WHERE lesson_id=%s ORDER BY position", (lesson_id,))
|
| 857 |
+
sections = cur.fetchall()
|
| 858 |
+
return {"lesson": lesson, "sections": sections}
|
| 859 |
+
|
| 860 |
+
# ---------- QUIZZES ----------
|
| 861 |
+
def create_quiz(lesson_id:int, title:str, items:list[dict], settings:dict|None=None):
|
| 862 |
+
with cursor() as cur:
|
| 863 |
+
cur.execute("INSERT INTO quizzes(lesson_id,title,settings) VALUES(%s,%s,%s)",
|
| 864 |
+
(lesson_id, title, json.dumps(settings or {})))
|
| 865 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 866 |
+
quiz_id = cur.fetchone()["id"]
|
| 867 |
+
for i, it in enumerate(items, start=1):
|
| 868 |
+
cur.execute("""
|
| 869 |
+
INSERT INTO quiz_items(quiz_id,position,question,options,answer_key,points)
|
| 870 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 871 |
+
""", (quiz_id, i, it["question"], json.dumps(it.get("options", [])),
|
| 872 |
+
json.dumps(it.get("answer_key")), int(it.get("points", 1))))
|
| 873 |
+
return quiz_id
|
| 874 |
+
|
| 875 |
+
def get_quiz(quiz_id:int):
|
| 876 |
+
with cursor() as cur:
|
| 877 |
+
cur.execute("SELECT * FROM quizzes WHERE quiz_id=%s", (quiz_id,))
|
| 878 |
+
quiz = cur.fetchone()
|
| 879 |
+
cur.execute("SELECT * FROM quiz_items WHERE quiz_id=%s ORDER BY position", (quiz_id,))
|
| 880 |
+
items = cur.fetchall()
|
| 881 |
+
return {"quiz": quiz, "items": items}
|
| 882 |
+
|
| 883 |
+
# ---------- ASSIGNMENTS ----------
|
| 884 |
+
def assign_to_class(lesson_id:int, quiz_id:int|None, class_id:int, teacher_id:int, due_at:str|None=None):
|
| 885 |
+
with cursor() as cur:
|
| 886 |
+
cur.execute("""
|
| 887 |
+
INSERT INTO assignments(lesson_id,quiz_id,class_id,assigned_by,due_at)
|
| 888 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 889 |
+
""", (lesson_id, quiz_id, class_id, teacher_id, due_at))
|
| 890 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 891 |
+
return cur.fetchone()["id"]
|
| 892 |
+
|
| 893 |
+
def assign_to_student(lesson_id:int, quiz_id:int|None, student_id:int, teacher_id:int, due_at:str|None=None):
|
| 894 |
+
with cursor() as cur:
|
| 895 |
+
cur.execute("""
|
| 896 |
+
INSERT INTO assignments(lesson_id,quiz_id,student_id,assigned_by,due_at)
|
| 897 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 898 |
+
""", (lesson_id, quiz_id, student_id, teacher_id, due_at))
|
| 899 |
+
cur.execute("SELECT LAST_INSERT_ID() AS id")
|
| 900 |
+
return cur.fetchone()["id"]
|
| 901 |
+
|
| 902 |
+
def list_assignments_for_student(student_id:int):
|
| 903 |
+
with cursor() as cur:
|
| 904 |
+
cur.execute("""
|
| 905 |
+
SELECT
|
| 906 |
+
a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
|
| 907 |
+
a.quiz_id, a.due_at,
|
| 908 |
+
COALESCE(lp.status,'not_started') AS status,
|
| 909 |
+
lp.current_pos
|
| 910 |
+
FROM (
|
| 911 |
+
SELECT
|
| 912 |
+
a.*,
|
| 913 |
+
ROW_NUMBER() OVER (
|
| 914 |
+
PARTITION BY a.lesson_id
|
| 915 |
+
ORDER BY a.created_at DESC, a.assignment_id DESC
|
| 916 |
+
) AS rn
|
| 917 |
+
FROM assignments a
|
| 918 |
+
WHERE a.student_id = %s
|
| 919 |
+
OR a.class_id IN (SELECT class_id FROM class_students WHERE student_id = %s)
|
| 920 |
+
) AS a
|
| 921 |
+
JOIN lessons l ON l.lesson_id = a.lesson_id
|
| 922 |
+
LEFT JOIN lesson_progress lp
|
| 923 |
+
ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
|
| 924 |
+
WHERE a.rn = 1
|
| 925 |
+
ORDER BY a.created_at DESC
|
| 926 |
+
""", (student_id, student_id, student_id))
|
| 927 |
+
return cur.fetchall()
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
# ---------- PROGRESS and SUBMISSIONS ----------
|
| 931 |
+
def save_progress(user_id:int, lesson_id:int, current_pos:int, status:str):
|
| 932 |
+
with cursor() as cur:
|
| 933 |
+
cur.execute("""
|
| 934 |
+
INSERT INTO lesson_progress(user_id,lesson_id,current_pos,status)
|
| 935 |
+
VALUES(%s,%s,%s,%s)
|
| 936 |
+
ON DUPLICATE KEY UPDATE current_pos=VALUES(current_pos), status=VALUES(status)
|
| 937 |
+
""", (user_id, lesson_id, current_pos, status))
|
| 938 |
+
return True
|
| 939 |
+
|
| 940 |
+
def submit_quiz(student_id:int, assignment_id:int, quiz_id:int, score:int, total:int, details:dict):
|
| 941 |
+
with cursor() as cur:
|
| 942 |
+
cur.execute("""
|
| 943 |
+
INSERT INTO submissions(assignment_id,quiz_id,student_id,score,total,details)
|
| 944 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 945 |
+
ON DUPLICATE KEY UPDATE score=VALUES(score), total=VALUES(total), details=VALUES(details), submitted_at=CURRENT_TIMESTAMP
|
| 946 |
+
""", (assignment_id, quiz_id, student_id, score, total, json.dumps(details)))
|
| 947 |
+
|
| 948 |
+
return True
|
| 949 |
+
|
| 950 |
+
# ---------- DASHBOARD SHORTCUTS ----------
|
| 951 |
+
def teacher_tiles(teacher_id:int):
|
| 952 |
+
with cursor() as cur:
|
| 953 |
+
cur.execute("SELECT * FROM v_class_stats WHERE teacher_id=%s", (teacher_id,))
|
| 954 |
+
rows = cur.fetchall()
|
| 955 |
+
total_students = sum(r["total_students"] for r in rows)
|
| 956 |
+
lessons_created = _count_lessons(teacher_id)
|
| 957 |
+
# use simple averages; adjust later as needed
|
| 958 |
+
class_avg = round(sum(r["class_avg"] for r in rows)/len(rows), 2) if rows else 0
|
| 959 |
+
active_students = sum(1 for r in rows if r.get("recent_submissions",0) > 0)
|
| 960 |
+
return dict(total_students=total_students, class_avg=class_avg, lessons_created=lessons_created, active_students=active_students)
|
| 961 |
+
|
| 962 |
+
def _count_lessons(teacher_id:int):
|
| 963 |
+
with cursor() as cur:
|
| 964 |
+
cur.execute("SELECT COUNT(*) AS n FROM lessons WHERE teacher_id=%s", (teacher_id,))
|
| 965 |
+
return cur.fetchone()["n"]
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
# --- XP and streak helpers ---
|
| 969 |
+
def user_xp_and_level(user_id:int):
|
| 970 |
+
with cursor() as cur:
|
| 971 |
+
cur.execute("SELECT COALESCE(SUM(delta),0) AS xp FROM xp_log WHERE user_id=%s", (user_id,))
|
| 972 |
+
xp = (cur.fetchone() or {"xp": 0})["xp"]
|
| 973 |
+
# simple leveling curve: every 500 XP is a level
|
| 974 |
+
level = max(1, (xp // 500) + 1)
|
| 975 |
+
# streak
|
| 976 |
+
cur.execute("SELECT days FROM streaks WHERE user_id=%s", (user_id,))
|
| 977 |
+
row = cur.fetchone()
|
| 978 |
+
streak = int(row["days"]) if row and row.get("days") is not None else 0
|
| 979 |
+
return dict(xp=int(xp), level=int(level), streak=streak)
|
| 980 |
+
|
| 981 |
+
def recent_lessons_for_student(user_id:int, limit:int=5):
|
| 982 |
+
with cursor() as cur:
|
| 983 |
+
cur.execute("""
|
| 984 |
+
SELECT l.title,
|
| 985 |
+
CASE WHEN lp.status='completed' THEN 100
|
| 986 |
+
WHEN lp.current_pos IS NULL THEN 0
|
| 987 |
+
ELSE LEAST(95, lp.current_pos * 10)
|
| 988 |
+
END AS progress
|
| 989 |
+
FROM lessons l
|
| 990 |
+
LEFT JOIN lesson_progress lp
|
| 991 |
+
ON lp.lesson_id=l.lesson_id AND lp.user_id=%s
|
| 992 |
+
WHERE l.lesson_id IN (
|
| 993 |
+
SELECT lesson_id FROM assignments
|
| 994 |
+
WHERE student_id=%s
|
| 995 |
+
OR class_id IN (SELECT class_id FROM class_students WHERE student_id=%s)
|
| 996 |
+
)
|
| 997 |
+
ORDER BY l.created_at DESC
|
| 998 |
+
LIMIT %s
|
| 999 |
+
""", (user_id, user_id, user_id, limit))
|
| 1000 |
+
return cur.fetchall()
|
| 1001 |
+
|
| 1002 |
+
def student_quiz_average(student_id: int) -> int:
|
| 1003 |
+
"""
|
| 1004 |
+
Returns the student's average quiz percentage (0–100) using the latest
|
| 1005 |
+
submission per quiz from the `submissions` table.
|
| 1006 |
+
"""
|
| 1007 |
+
with cursor() as cur:
|
| 1008 |
+
cur.execute("""
|
| 1009 |
+
WITH latest AS (
|
| 1010 |
+
SELECT quiz_id, MAX(submitted_at) AS last_ts
|
| 1011 |
+
FROM submissions
|
| 1012 |
+
WHERE student_id = %s
|
| 1013 |
+
GROUP BY quiz_id
|
| 1014 |
+
)
|
| 1015 |
+
SELECT ROUND(AVG(s.score * 100.0 / NULLIF(s.total,0))) AS pct
|
| 1016 |
+
FROM latest t
|
| 1017 |
+
JOIN submissions s
|
| 1018 |
+
ON s.quiz_id = t.quiz_id
|
| 1019 |
+
AND s.submitted_at = t.last_ts
|
| 1020 |
+
WHERE s.student_id = %s
|
| 1021 |
+
""", (student_id, student_id))
|
| 1022 |
+
row = cur.fetchone() or {}
|
| 1023 |
+
return int(row.get("pct") or 0)
|
| 1024 |
+
|
| 1025 |
+
# --- Generic XP bump and streak touch ---
|
| 1026 |
+
def add_xp(user_id:int, delta:int, source:str, meta:dict|None=None):
|
| 1027 |
+
with cursor() as cur:
|
| 1028 |
+
cur.execute(
|
| 1029 |
+
"INSERT INTO xp_log(user_id,source,delta,meta) VALUES(%s,%s,%s,%s)",
|
| 1030 |
+
(user_id, source, int(delta), json.dumps(meta or {}))
|
| 1031 |
+
)
|
| 1032 |
+
# streak touch
|
| 1033 |
+
cur.execute("SELECT days, last_active FROM streaks WHERE user_id=%s", (user_id,))
|
| 1034 |
+
row = cur.fetchone()
|
| 1035 |
+
today = date.today()
|
| 1036 |
+
if not row:
|
| 1037 |
+
cur.execute("INSERT INTO streaks(user_id,days,last_active) VALUES(%s,%s,%s)", (user_id, 1, today))
|
| 1038 |
+
else:
|
| 1039 |
+
last = row["last_active"]
|
| 1040 |
+
days = int(row["days"] or 0)
|
| 1041 |
+
if last is None or last < today:
|
| 1042 |
+
# if we missed a day, reset to 1 else +1
|
| 1043 |
+
if last and (today - last) > timedelta(days=1):
|
| 1044 |
+
days = 1
|
| 1045 |
+
else:
|
| 1046 |
+
days = max(1, days + 1)
|
| 1047 |
+
cur.execute("UPDATE streaks SET days=%s,last_active=%s WHERE user_id=%s", (days, today, user_id))
|
| 1048 |
+
|
| 1049 |
+
# -- leaderboard helpders ---
|
| 1050 |
+
|
| 1051 |
+
def leaderboard_for_class(class_id: int, limit: int = 10):
|
| 1052 |
+
"""
|
| 1053 |
+
Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
|
| 1054 |
+
Sorted by XP (desc) for students in a specific class.
|
| 1055 |
+
"""
|
| 1056 |
+
with cursor() as cur:
|
| 1057 |
+
cur.execute("""
|
| 1058 |
+
SELECT
|
| 1059 |
+
u.user_id,
|
| 1060 |
+
u.name,
|
| 1061 |
+
COALESCE(x.total_xp, 0) AS xp
|
| 1062 |
+
FROM class_students cs
|
| 1063 |
+
JOIN users u ON u.user_id = cs.student_id
|
| 1064 |
+
LEFT JOIN (
|
| 1065 |
+
SELECT user_id, SUM(delta) AS total_xp
|
| 1066 |
+
FROM xp_log
|
| 1067 |
+
GROUP BY user_id
|
| 1068 |
+
) x ON x.user_id = u.user_id
|
| 1069 |
+
WHERE cs.class_id = %s
|
| 1070 |
+
ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
|
| 1071 |
+
LIMIT %s
|
| 1072 |
+
""", (class_id, limit))
|
| 1073 |
+
rows = cur.fetchall() or []
|
| 1074 |
+
# attach levels using curve
|
| 1075 |
+
for r in rows:
|
| 1076 |
+
r["level"] = level_from_xp(r.get("xp", 0))
|
| 1077 |
+
return rows
|
| 1078 |
+
|
| 1079 |
+
|
| 1080 |
+
def leaderboard_global(limit: int = 10):
|
| 1081 |
+
"""
|
| 1082 |
+
Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
|
| 1083 |
+
Top students across the whole app by XP.
|
| 1084 |
+
"""
|
| 1085 |
+
with cursor() as cur:
|
| 1086 |
+
cur.execute("""
|
| 1087 |
+
SELECT
|
| 1088 |
+
u.user_id,
|
| 1089 |
+
u.name,
|
| 1090 |
+
COALESCE(x.total_xp, 0) AS xp
|
| 1091 |
+
FROM users u
|
| 1092 |
+
LEFT JOIN (
|
| 1093 |
+
SELECT user_id, SUM(delta) AS total_xp
|
| 1094 |
+
FROM xp_log
|
| 1095 |
+
GROUP BY user_id
|
| 1096 |
+
) x ON x.user_id = u.user_id
|
| 1097 |
+
WHERE u.role_slug = 'student'
|
| 1098 |
+
ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
|
| 1099 |
+
LIMIT %s
|
| 1100 |
+
""", (limit,))
|
| 1101 |
+
rows = cur.fetchall() or []
|
| 1102 |
+
for r in rows:
|
| 1103 |
+
r["level"] = level_from_xp(r.get("xp", 0))
|
| 1104 |
+
return rows
|
| 1105 |
+
|
| 1106 |
+
|
| 1107 |
+
|
| 1108 |
+
|
| 1109 |
+
# --- Game logging helpers ---
|
| 1110 |
+
def record_money_match_play(user_id:int, *, target:int, total:int, elapsed_ms:int, matched:bool, gained_xp:int):
|
| 1111 |
+
with cursor() as cur:
|
| 1112 |
+
cur.execute("""
|
| 1113 |
+
INSERT INTO game_sessions(user_id,game_slug,target,total,elapsed_ms,matched,gained_xp,ended_at)
|
| 1114 |
+
VALUES(%s,'money_match',%s,%s,%s,%s,%s,NOW())
|
| 1115 |
+
""", (user_id, target, total, elapsed_ms, 1 if matched else 0, gained_xp))
|
| 1116 |
+
cur.execute("""
|
| 1117 |
+
INSERT INTO money_match_history(user_id,target,total,elapsed_ms,gained_xp,matched)
|
| 1118 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 1119 |
+
""", (user_id, target, total, elapsed_ms, gained_xp, 1 if matched else 0))
|
| 1120 |
+
cur.execute("""
|
| 1121 |
+
INSERT INTO money_match_stats(user_id,total_xp,matches,best_time_ms,best_target)
|
| 1122 |
+
VALUES(%s,%s,%s,%s,%s)
|
| 1123 |
+
ON DUPLICATE KEY UPDATE
|
| 1124 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1125 |
+
matches = matches + VALUES(matches),
|
| 1126 |
+
best_time_ms = LEAST(COALESCE(best_time_ms, VALUES(best_time_ms)), VALUES(best_time_ms)),
|
| 1127 |
+
best_target = COALESCE(best_target, VALUES(best_target))
|
| 1128 |
+
""", (user_id, gained_xp, 1 if matched else 0, elapsed_ms if matched else None, target if matched else None))
|
| 1129 |
+
|
| 1130 |
+
_bump_game_stats(user_id, "money_match", gained_xp=gained_xp, matched=1 if matched else 0)
|
| 1131 |
+
add_xp(user_id, gained_xp, "game", {"game":"money_match","target":target,"total":total,"elapsed_ms":elapsed_ms,"matched":matched})
|
| 1132 |
+
|
| 1133 |
+
def record_budget_builder_save(user_id:int, *, weekly_allowance:int, allocations:list[dict]):
|
| 1134 |
+
total_allocated = sum(int(x.get("amount",0)) for x in allocations)
|
| 1135 |
+
remaining = int(weekly_allowance) - total_allocated
|
| 1136 |
+
gained_xp = 150 if remaining == 0 else 100 if remaining > 0 else 50
|
| 1137 |
+
with cursor() as cur:
|
| 1138 |
+
cur.execute("""
|
| 1139 |
+
INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
|
| 1140 |
+
VALUES(%s,'budget_builder',%s,NOW())
|
| 1141 |
+
""", (user_id, gained_xp))
|
| 1142 |
+
cur.execute("""
|
| 1143 |
+
INSERT INTO budget_builder_history(user_id,weekly_allowance,allocations,total_allocated,remaining,gained_xp)
|
| 1144 |
+
VALUES(%s,%s,%s,%s,%s,%s)
|
| 1145 |
+
""", (user_id, weekly_allowance, json.dumps(allocations), total_allocated, remaining, gained_xp))
|
| 1146 |
+
cur.execute("""
|
| 1147 |
+
INSERT INTO budget_builder_stats(user_id,total_xp,plays,best_balance)
|
| 1148 |
+
VALUES(%s,%s,1,%s)
|
| 1149 |
+
ON DUPLICATE KEY UPDATE
|
| 1150 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1151 |
+
plays = plays + 1,
|
| 1152 |
+
best_balance = GREATEST(COALESCE(best_balance, 0), VALUES(best_balance))
|
| 1153 |
+
""", (user_id, gained_xp, remaining))
|
| 1154 |
+
|
| 1155 |
+
_bump_game_stats(user_id, "budget_builder", gained_xp=gained_xp, matched=1)
|
| 1156 |
+
add_xp(user_id, gained_xp, "game", {"game":"budget_builder","remaining":remaining})
|
| 1157 |
+
|
| 1158 |
+
def record_debt_dilemma_round(
|
| 1159 |
+
user_id:int, *,
|
| 1160 |
+
level:int, round_no:int,
|
| 1161 |
+
wallet:int, health:int, happiness:int, credit_score:int,
|
| 1162 |
+
event_json:dict, outcome:str, gained_xp:int
|
| 1163 |
+
):
|
| 1164 |
+
with cursor() as cur:
|
| 1165 |
+
cur.execute("""
|
| 1166 |
+
INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
|
| 1167 |
+
VALUES(%s,'debt_dilemma',%s,NOW())
|
| 1168 |
+
""", (user_id, gained_xp))
|
| 1169 |
+
cur.execute("""
|
| 1170 |
+
INSERT INTO debt_dilemma_history(user_id,level,round_no,wallet,health,happiness,credit_score,event_json,outcome,gained_xp)
|
| 1171 |
+
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
| 1172 |
+
""", (user_id, level, round_no, wallet, health, happiness, credit_score, json.dumps(event_json or {}), outcome, gained_xp))
|
| 1173 |
+
cur.execute("""
|
| 1174 |
+
INSERT INTO debt_dilemma_stats(user_id,total_xp,plays,highest_level,last_outcome)
|
| 1175 |
+
VALUES(%s,%s,1,%s,%s)
|
| 1176 |
+
ON DUPLICATE KEY UPDATE
|
| 1177 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1178 |
+
plays = plays + 1,
|
| 1179 |
+
highest_level = GREATEST(COALESCE(highest_level,0), VALUES(highest_level)),
|
| 1180 |
+
last_outcome = VALUES(last_outcome)
|
| 1181 |
+
""", (user_id, gained_xp, level, outcome))
|
| 1182 |
+
|
| 1183 |
+
# Treat a completed month/level as a "match"
|
| 1184 |
+
_bump_game_stats(user_id, "debt_dilemma", gained_xp=gained_xp, matched=1, level_inc=level)
|
| 1185 |
+
add_xp(user_id, gained_xp, "game", {
|
| 1186 |
+
"game":"debt_dilemma","level":level,"round":round_no,"outcome":outcome
|
| 1187 |
+
})
|
| 1188 |
+
|
| 1189 |
+
|
| 1190 |
+
def record_profit_puzzle_result(
|
| 1191 |
+
user_id:int, *,
|
| 1192 |
+
scenario_id:str,
|
| 1193 |
+
title:str,
|
| 1194 |
+
units:int, price:int, cost:int,
|
| 1195 |
+
user_answer:float, actual_profit:float,
|
| 1196 |
+
is_correct:bool, gained_xp:int
|
| 1197 |
+
):
|
| 1198 |
+
with cursor() as cur:
|
| 1199 |
+
# generic session row for cross-game views
|
| 1200 |
+
cur.execute("""
|
| 1201 |
+
INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
|
| 1202 |
+
VALUES(%s,'profit_puzzle',%s,NOW())
|
| 1203 |
+
""", (user_id, int(gained_xp)))
|
| 1204 |
+
|
| 1205 |
+
# detailed history
|
| 1206 |
+
cur.execute("""
|
| 1207 |
+
INSERT INTO profit_puzzle_history
|
| 1208 |
+
(user_id,scenario_id,title,units,price,cost,user_answer,actual_profit,is_correct,gained_xp)
|
| 1209 |
+
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
| 1210 |
+
""", (user_id, scenario_id, title, int(units), int(price), int(cost),
|
| 1211 |
+
float(user_answer), float(actual_profit), 1 if is_correct else 0, int(gained_xp)))
|
| 1212 |
+
|
| 1213 |
+
# per-game stats
|
| 1214 |
+
cur.execute("""
|
| 1215 |
+
INSERT INTO profit_puzzle_stats(user_id,total_xp,plays,correct,last_score)
|
| 1216 |
+
VALUES(%s,%s,1,%s,%s)
|
| 1217 |
+
ON DUPLICATE KEY UPDATE
|
| 1218 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1219 |
+
plays = plays + 1,
|
| 1220 |
+
correct = correct + VALUES(correct),
|
| 1221 |
+
last_score = VALUES(last_score),
|
| 1222 |
+
last_played = CURRENT_TIMESTAMP
|
| 1223 |
+
""", (user_id, int(gained_xp), 1 if is_correct else 0, int(gained_xp)))
|
| 1224 |
+
|
| 1225 |
+
# game_stats rollup like other games
|
| 1226 |
+
cur.execute("""
|
| 1227 |
+
INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
|
| 1228 |
+
VALUES(%s,'profit_puzzle',%s,%s,1)
|
| 1229 |
+
ON DUPLICATE KEY UPDATE
|
| 1230 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1231 |
+
matches = matches + VALUES(matches)
|
| 1232 |
+
""", (user_id, int(gained_xp), 1 if is_correct else 0))
|
| 1233 |
+
|
| 1234 |
+
# global XP and streak
|
| 1235 |
+
add_xp(user_id, int(gained_xp), "game",
|
| 1236 |
+
{"game":"profit_puzzle","scenario":scenario_id,"correct":bool(is_correct)})
|
| 1237 |
+
|
| 1238 |
+
# --- Profit Puzzle logging ---
|
| 1239 |
+
def record_profit_puzzle_progress(user_id:int, *, scenario_title:str, correct:bool, gained_xp:int):
|
| 1240 |
+
"""
|
| 1241 |
+
Log a Profit Puzzle step and bump XP.
|
| 1242 |
+
- Writes to generic game_sessions and game_stats
|
| 1243 |
+
- Writes to xp_log via add_xp
|
| 1244 |
+
"""
|
| 1245 |
+
with cursor() as cur:
|
| 1246 |
+
# session line item
|
| 1247 |
+
cur.execute("""
|
| 1248 |
+
INSERT INTO game_sessions(user_id, game_slug, gained_xp, ended_at)
|
| 1249 |
+
VALUES(%s, 'profit_puzzle', %s, NOW())
|
| 1250 |
+
""", (user_id, int(gained_xp)))
|
| 1251 |
+
|
| 1252 |
+
# aggregate by game
|
| 1253 |
+
cur.execute("""
|
| 1254 |
+
INSERT INTO game_stats(user_id, game_slug, total_xp, matches, level)
|
| 1255 |
+
VALUES(%s, 'profit_puzzle', %s, %s, 1)
|
| 1256 |
+
ON DUPLICATE KEY UPDATE
|
| 1257 |
+
total_xp = total_xp + VALUES(total_xp),
|
| 1258 |
+
matches = matches + VALUES(matches)
|
| 1259 |
+
""", (user_id, int(gained_xp), 1 if correct else 0))
|
| 1260 |
+
|
| 1261 |
+
add_xp(
|
| 1262 |
+
user_id,
|
| 1263 |
+
int(gained_xp),
|
| 1264 |
+
"game",
|
| 1265 |
+
{"game": "profit_puzzle", "scenario": scenario_title, "correct": bool(correct)}
|
| 1266 |
+
)
|
utils/graph.py
ADDED
|
File without changes
|
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 |
+
}
|
utils/seed.py
ADDED
|
File without changes
|