lanna_lalala;- commited on
Commit
bdbcf73
·
1 Parent(s): 758272f

feat: import backend code (clean history)

Browse files
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

  • SHA256: 7e047e6c00973923137ee8073ac18e1eeaff6d652abcc09244f36e4d3409dbc1
  • Pointer size: 130 Bytes
  • Size of remote file: 11 kB
assets/images/jmd/jmd_10.jpeg ADDED

Git LFS Details

  • SHA256: 430ab66313ee96fb1b6427b98940c48ed53c7b4661e2209256c15b0e7f22c798
  • Pointer size: 129 Bytes
  • Size of remote file: 8.61 kB
assets/images/jmd/jmd_100.jpg ADDED

Git LFS Details

  • SHA256: ba775c8f9d72742adb91daea14d5fafe6ebbbd29937e6f18fe081ea7ce63c5a4
  • Pointer size: 130 Bytes
  • Size of remote file: 60.6 kB
assets/images/jmd/jmd_1000.jpeg ADDED

Git LFS Details

  • SHA256: 9f68b3ed1b6c7d36e0a522f57f2d14047256f80c2b960f330497f0b19f68938e
  • Pointer size: 130 Bytes
  • Size of remote file: 11.6 kB
assets/images/jmd/jmd_20.jpeg ADDED

Git LFS Details

  • SHA256: c3356572fb5a5b5375c8448b4011e9c51de294306aafd90f99459dc296a81d7d
  • Pointer size: 129 Bytes
  • Size of remote file: 7.32 kB
assets/images/jmd/jmd_2000.jpeg ADDED

Git LFS Details

  • SHA256: 61ef89590412d583148c5448465aaa8615af6071b53c89ac9346bba33720b1ee
  • Pointer size: 130 Bytes
  • Size of remote file: 12.1 kB
assets/images/jmd/jmd_5.jpeg ADDED

Git LFS Details

  • SHA256: 6e889f57d27be811523b22a7c8d9e0f1e03eda2fffd843fc77c02b6db173fe8c
  • Pointer size: 130 Bytes
  • Size of remote file: 13.4 kB
assets/images/jmd/jmd_50.jpg ADDED

Git LFS Details

  • SHA256: 48ce514987526824aa5735745e10f292bd5307c7120a42d949cb72af0dae9d2a
  • Pointer size: 131 Bytes
  • Size of remote file: 355 kB
assets/images/jmd/jmd_500.jpg ADDED

Git LFS Details

  • SHA256: a63b4d46ff2b05687b07376eaad2ddc492655f5aadf91d3b9438c50b5cdca714
  • Pointer size: 130 Bytes
  • Size of remote file: 67.9 kB
assets/images/jmd/jmd_5000.jpeg ADDED

Git LFS Details

  • SHA256: dfd724184bc9f6b9bc01791a0e531d0c49127e436840700836669f2741bf7394
  • Pointer size: 130 Bytes
  • Size of remote file: 11.9 kB
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