lanna_lalala;- commited on
Commit
0aa6283
·
1 Parent(s): aa773f7

added folders

Browse files
app.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ st.set_page_config(
3
+ page_title="Financial Education App",
4
+ page_icon="💹",
5
+ layout="centered",
6
+ initial_sidebar_state="expanded"
7
+ )
8
+
9
+ from secrets import choice
10
+ from dashboards import student_db,teacher_db
11
+ from phase.Student_view import chatbot, lesson, quiz, game, teacherlink
12
+ from phase.Teacher_view import classmanage,studentlist,contentmanage
13
+ from phase.Student_view.games import profitpuzzle
14
+ from utils import db,api
15
+ import os, requests
16
+
17
+ from utils.api import BACKEND
18
+ st.sidebar.caption(f"Backend URL: {BACKEND}")
19
+
20
+
21
+ DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
22
+
23
+ try:
24
+ ok = api.health().get("ok")
25
+ if ok:
26
+ st.sidebar.success("Backend: UP")
27
+ else:
28
+ st.sidebar.warning("Backend: ?")
29
+ except Exception as e:
30
+ st.sidebar.error(f"Backend DOWN: {e}")
31
+
32
+
33
+ # --- SESSION STATE INITIALIZATION ---
34
+ for key, default in [("user", None), ("current_page", "Welcome"),
35
+ ("xp", 2450), ("streak", 7), ("current_game", None),
36
+ ("temp_user", None)]:
37
+ if key not in st.session_state:
38
+ st.session_state[key] = default
39
+
40
+ # --- NAVIGATION ---
41
+ def setup_navigation():
42
+ if st.session_state.user:
43
+ public_pages = ["Welcome", "Login"]
44
+ else:
45
+ public_pages = ["Welcome", "Signup", "Login"]
46
+
47
+ nav_choice = st.sidebar.selectbox(
48
+ "Go to",
49
+ public_pages,
50
+ index=public_pages.index(st.session_state.current_page) if st.session_state.current_page in public_pages else 0
51
+ )
52
+
53
+ # --- if quiz is in progress, show progress tracker ---
54
+ if st.session_state.get("current_page") == "Quiz":
55
+ qid = st.session_state.get("selected_quiz")
56
+ if qid is not None:
57
+ try:
58
+ quiz.show_quiz_progress_sidebar(qid) # renders into sidebar
59
+ except Exception:
60
+ pass
61
+
62
+
63
+ # --- if profit puzzle game is in progress, show progress tracker ---
64
+ if (
65
+ st.session_state.get("current_page") == "Game"
66
+ and st.session_state.get("current_game") == "profit_puzzle"
67
+ ):
68
+ profitpuzzle.show_profit_progress_sidebar()
69
+
70
+ # Only override if user is already on a public page
71
+ if st.session_state.current_page in public_pages:
72
+ st.session_state.current_page = nav_choice
73
+
74
+ if st.session_state.user:
75
+ st.sidebar.markdown("---")
76
+ st.sidebar.subheader("Dashboard")
77
+ role = st.session_state.user["role"]
78
+
79
+ if role == "Student":
80
+ if st.sidebar.button("📊 Student Dashboard"):
81
+ st.session_state.current_page = "Student Dashboard"
82
+ if st.sidebar.button("📘 Lessons"):
83
+ st.session_state.current_page = "Lessons"
84
+ if st.sidebar.button("📝 Quiz"):
85
+ st.session_state.current_page = "Quiz"
86
+ if st.sidebar.button("💬 Chatbot"):
87
+ st.session_state.current_page = "Chatbot"
88
+ if st.sidebar.button("🏆 Game"):
89
+ st.session_state.current_page = "Game"
90
+ if st.sidebar.button("⌨️​ Teacher Link"):
91
+ st.session_state.current_page = "Teacher Link"
92
+
93
+ elif role == "Teacher":
94
+ if st.sidebar.button("📚 Teacher Dashboard"):
95
+ st.session_state.current_page = "Teacher Dashboard"
96
+ if st.sidebar.button("Class management"):
97
+ st.session_state.current_page = "Class management"
98
+ if st.sidebar.button("Students List"):
99
+ st.session_state.current_page = "Students List"
100
+ if st.sidebar.button("Content Management"):
101
+ st.session_state.current_page = "Content Management"
102
+
103
+ if st.sidebar.button("Logout"):
104
+ st.session_state.user = None
105
+ st.session_state.current_page = "Welcome"
106
+ st.rerun()
107
+
108
+
109
+ # --- ROUTING ---
110
+ def main():
111
+ setup_navigation()
112
+ page = st.session_state.current_page
113
+
114
+ # --- WELCOME PAGE ---
115
+ if page == "Welcome":
116
+ st.title("💹 Welcome to FinEdu App")
117
+ if st.session_state.user:
118
+ st.success(f"Welcome back, {st.session_state.user['name']}! ✅")
119
+ st.write(
120
+ "This app helps you improve your **financial education and numeracy skills**. \n"
121
+ "👉 Use the sidebar to **Signup** or **Login** to get started."
122
+ )
123
+
124
+ # --- SIGNUP PAGE ---
125
+ elif page == "Signup":
126
+ st.title("📝 Signup")
127
+
128
+ # remember the picked role between reruns
129
+ if "signup_role" not in st.session_state:
130
+ st.session_state.signup_role = None
131
+
132
+ if st.session_state.user:
133
+ st.success(f"Already logged in as {st.session_state.user['name']}.")
134
+ st.stop()
135
+
136
+ # Step 1: choose role
137
+ if not st.session_state.signup_role:
138
+ st.subheader("Who are you signing up as?")
139
+ c1, c2 = st.columns(2)
140
+ with c1:
141
+ if st.button("👩‍🎓 Student", use_container_width=True):
142
+ st.session_state.signup_role = "Student"
143
+ st.rerun()
144
+ with c2:
145
+ if st.button("👨‍🏫 Teacher", use_container_width=True):
146
+ st.session_state.signup_role = "Teacher"
147
+ st.rerun()
148
+
149
+ st.info("Pick your role to continue with the correct form.")
150
+ st.stop()
151
+
152
+ role = st.session_state.signup_role
153
+
154
+ # Step 2a: Student form
155
+ if role == "Student":
156
+ st.subheader("Student Signup")
157
+ with st.form("student_signup_form", clear_on_submit=False):
158
+ name = st.text_input("Full Name")
159
+ email = st.text_input("Email")
160
+ password = st.text_input("Password", type="password")
161
+ country = st.selectbox("Country", ["Jamaica", "USA", "UK", "India", "Canada", "Other"])
162
+ level = st.selectbox("Level", ["Beginner", "Intermediate", "Advanced"])
163
+ submitted = st.form_submit_button("Create Student Account")
164
+
165
+ if submitted:
166
+ if not (name.strip() and email.strip() and password.strip()):
167
+ st.error("⚠️ Please complete all required fields.")
168
+ st.stop()
169
+
170
+ if DISABLE_DB:
171
+ try:
172
+ api.signup_student(
173
+ name=name.strip(),
174
+ email=email.strip().lower(),
175
+ password=password,
176
+ level_label=level, # <-- keep these names
177
+ country_label=country,
178
+ )
179
+ st.success("✅ Signup successful! Please go to the **Login** page to continue.")
180
+ st.session_state.current_page = "Login"
181
+ st.session_state.signup_role = None
182
+ st.rerun()
183
+ except Exception as e:
184
+ st.error(f"❌ Signup failed: {e}")
185
+ else:
186
+ # Local DB path (unchanged)
187
+ conn = db.get_db_connection()
188
+ if not conn:
189
+ st.error("❌ Unable to connect to the database.")
190
+ st.stop()
191
+ try:
192
+ ok = db.create_student(
193
+ name=name, email=email, password=password,
194
+ level_label=level, country_label=country
195
+ )
196
+ if ok:
197
+ st.success("✅ Signup successful! Please go to the **Login** page to continue.")
198
+ st.session_state.current_page = "Login"
199
+ st.session_state.signup_role = None
200
+ st.rerun()
201
+ else:
202
+ st.error("❌ Failed to create user. Email may already exist.")
203
+ finally:
204
+ conn.close()
205
+
206
+ # Step 2b: Teacher form
207
+ elif role == "Teacher":
208
+ st.subheader("Teacher Signup")
209
+ with st.form("teacher_signup_form", clear_on_submit=False):
210
+ title = st.selectbox("Title", ["Mr", "Ms", "Miss", "Mrs", "Dr", "Prof", "Other"])
211
+ name = st.text_input("Full Name")
212
+ email = st.text_input("Email")
213
+ password = st.text_input("Password", type="password")
214
+ submitted = st.form_submit_button("Create Teacher Account")
215
+
216
+ if submitted:
217
+ if not (title.strip() and name.strip() and email.strip() and password.strip()):
218
+ st.error("⚠️ Please complete all required fields.")
219
+ st.stop()
220
+
221
+ if DISABLE_DB:
222
+ try:
223
+ api.signup_teacher(
224
+ title=title.strip(),
225
+ name=name.strip(),
226
+ email=email.strip().lower(),
227
+ password=password,
228
+ )
229
+ st.success("✅ Signup successful! Please go to the **Login** page to continue.")
230
+ st.session_state.current_page = "Login"
231
+ st.session_state.signup_role = None
232
+ st.rerun()
233
+ except Exception as e:
234
+ st.error(f"❌ Signup failed: {e}")
235
+ else:
236
+ conn = db.get_db_connection()
237
+ if not conn:
238
+ st.error("❌ Unable to connect to the database.")
239
+ st.stop()
240
+ try:
241
+ ok = db.create_teacher(
242
+ title=title, name=name, email=email, password=password
243
+ )
244
+ if ok:
245
+ st.success("✅ Signup successful! Please go to the **Login** page to continue.")
246
+ st.session_state.current_page = "Login"
247
+ st.session_state.signup_role = None
248
+ st.rerun()
249
+ else:
250
+ st.error("❌ Failed to create user. Email may already exist.")
251
+ finally:
252
+ conn.close()
253
+
254
+ # Allow changing role without going back manually
255
+ if st.button("⬅️ Choose a different role"):
256
+ st.session_state.signup_role = None
257
+ st.rerun()
258
+
259
+ # --- LOGIN PAGE ---
260
+ elif page == "Login":
261
+ st.title("🔑 Login")
262
+ if st.session_state.user:
263
+ st.success(f"Welcome, {st.session_state.user['name']}! ✅")
264
+ else:
265
+ with st.form("login_form"):
266
+ email = st.text_input("Email")
267
+ password = st.text_input("Password", type="password")
268
+ submit = st.form_submit_button("Login")
269
+
270
+ if submit:
271
+ if DISABLE_DB:
272
+ # Route login to your Backend Space
273
+ try:
274
+ user = api.login(email, password) # calls POST /auth/login
275
+ # Normalize to the structure your app already uses
276
+ st.session_state.user = {
277
+ "user_id": user["user_id"],
278
+ "name": user["name"],
279
+ "role": user["role"], # "Student" or "Teacher" from backend
280
+ "email": user["email"],
281
+ }
282
+ st.success(f"🎉 Logged in as {user['name']} ({user['role']})")
283
+ st.session_state.current_page = (
284
+ "Student Dashboard" if user["role"] == "Student" else "Teacher Dashboard"
285
+ )
286
+ st.rerun()
287
+ except Exception as e:
288
+ st.error(f"Login failed. {e}")
289
+ else:
290
+ # Local fallback: keep your old direct-DB logic
291
+ conn = db.get_db_connection()
292
+ if not conn:
293
+ st.error("❌ Unable to connect to the database.")
294
+ else:
295
+ try:
296
+ user = db.check_password(email, password)
297
+ if user:
298
+ st.session_state.user = {
299
+ "user_id": user["user_id"],
300
+ "name": user["name"],
301
+ "role": user["role"], # "Student" or "Teacher"
302
+ "email": user["email"],
303
+ }
304
+ st.success(f"🎉 Logged in as {user['name']} ({user['role']})")
305
+ st.session_state.current_page = (
306
+ "Student Dashboard" if user["role"] == "Student" else "Teacher Dashboard"
307
+ )
308
+ st.rerun()
309
+ else:
310
+ st.error("❌ Incorrect email or password, or account not found.")
311
+ finally:
312
+ conn.close()
313
+
314
+ # --- STUDENT DASHBOARD ---
315
+ elif page == "Student Dashboard":
316
+ if not st.session_state.user:
317
+ st.error("❌ Please login first.")
318
+ st.session_state.current_page = "Login"
319
+ st.rerun()
320
+ elif st.session_state.user["role"] != "Student":
321
+ st.error("🚫 Only students can access this page.")
322
+ st.session_state.current_page = "Welcome"
323
+ st.rerun()
324
+ else:
325
+ student_db.show_student_dashboard()
326
+
327
+ # --- TEACHER DASHBOARD ---
328
+ elif page == "Teacher Dashboard":
329
+ if not st.session_state.user:
330
+ st.error("❌ Please login first.")
331
+ st.session_state.current_page = "Login"
332
+ st.rerun()
333
+ elif st.session_state.user["role"] != "Teacher":
334
+ st.error("🚫 Only teachers can access this page.")
335
+ st.session_state.current_page = "Welcome"
336
+ st.rerun()
337
+ else:
338
+ teacher_db.show_teacher_dashboard()
339
+
340
+
341
+ # --- PRIVATE PAGES ---
342
+ private_pages_map = {
343
+ "Lessons": lesson.show_page,
344
+ "Quiz": quiz.show_page,
345
+ "Chatbot": chatbot.show_page,
346
+ "Game": game.show_games,
347
+ "Teacher Link": teacherlink.show_code,
348
+ "Class management": classmanage.show_page,
349
+ "Students List": studentlist.show_page,
350
+ "Content Management": contentmanage.show_page
351
+ }
352
+
353
+ if page in private_pages_map:
354
+ if not st.session_state.user:
355
+ st.error("❌ Please login first.")
356
+ st.session_state.current_page = "Login"
357
+ st.rerun()
358
+ elif page in ["Lessons", "Quiz", "Chatbot", "Game", "Teacher Link"] and st.session_state.user["role"] == "Student":
359
+ private_pages_map[page]()
360
+ elif page in ["Class management", "Students List", "Content Management"] and st.session_state.user["role"] == "Teacher":
361
+ private_pages_map[page]()
362
+ else:
363
+ st.error("🚫 You don’t have access to this page.")
364
+ st.session_state.current_page = "Welcome"
365
+ st.rerun()
366
+
367
+
368
+
369
+ if __name__ == "__main__":
370
+ main()
371
+
assets/images/jmd/jmd_1.jpeg ADDED

Git LFS Details

  • 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,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import streamlit as st
3
+ from phase.Student_view import chatbot, lesson, quiz
4
+ from utils import db as dbapi
5
+ import utils.api as api # <-- backend Space client
6
+
7
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
8
+
9
+ # --- Load external CSS ---
10
+ def load_css(file_name: str):
11
+ try:
12
+ with open(file_name, "r", encoding="utf-8") as f:
13
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
14
+ except FileNotFoundError:
15
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
16
+
17
+ def show_student_dashboard():
18
+ # Load CSS
19
+ css_path = os.path.join("assets", "styles.css")
20
+ load_css(css_path)
21
+
22
+ # Current user
23
+ user = st.session_state.user
24
+ name = user["name"]
25
+ student_id = user["user_id"]
26
+
27
+ # --- Real metrics from DB ---
28
+ # Requires helper funcs in utils/db.py: user_xp_and_level, recent_lessons_for_student, list_assignments_for_student
29
+ if USE_LOCAL_DB and hasattr(dbapi, "user_xp_and_level"):
30
+ stats = dbapi.user_xp_and_level(student_id)
31
+ else:
32
+ # Try backend; fall back to defaults if not available yet
33
+ try:
34
+ stats = api.user_stats(student_id)
35
+ except Exception:
36
+ stats = {"xp": 0, "level": 1, "streak": 0}
37
+
38
+ xp = int(stats.get("xp", 0))
39
+ level = int(stats.get("level", 1))
40
+ study_streak = int(stats.get("streak", 0))
41
+
42
+ # # Cap for the visual bar
43
+ # max_xp = max(500, ((xp // 500) + 1) * 500)
44
+
45
+ # Assignments for “My Work”
46
+ if USE_LOCAL_DB and hasattr(dbapi, "list_assignments_for_student"):
47
+ rows = dbapi.list_assignments_for_student(student_id)
48
+ else:
49
+ try:
50
+ rows = api.list_assignments_for_student(student_id)
51
+ except Exception:
52
+ rows = []
53
+
54
+ def _pct_from_row(r: dict):
55
+ sp = r.get("score_pct")
56
+ if sp is not None:
57
+ try:
58
+ return int(round(float(sp)))
59
+ except Exception:
60
+ pass
61
+ s, t = r.get("score"), r.get("total")
62
+ if s is not None and t not in (None, 0):
63
+ try:
64
+ return int(round((float(s) / float(t)) * 100))
65
+ except Exception:
66
+ return None
67
+ return None
68
+
69
+ if USE_LOCAL_DB and hasattr(dbapi, "student_quiz_average"):
70
+ quiz_score = dbapi.student_quiz_average(student_id)
71
+ else:
72
+ try:
73
+ quiz_score = api.student_quiz_average(student_id)
74
+ except Exception:
75
+ quiz_score = 0
76
+
77
+ lessons_completed = sum(1 for r in rows if r.get("status") == "completed" or _pct_from_row(r) == 100)
78
+ total_lessons = len(rows)
79
+
80
+ # Recent lessons assigned to this student
81
+ if USE_LOCAL_DB and hasattr(dbapi, "recent_lessons_for_student"):
82
+ recent_lessons = dbapi.recent_lessons_for_student(student_id, limit=5)
83
+ else:
84
+ try:
85
+ recent_lessons = api.recent_lessons_for_student(student_id, limit=5)
86
+ except Exception:
87
+ recent_lessons = []
88
+
89
+ # Daily Challenge derived from real data
90
+ challenge_difficulty = "Easy" if level < 3 else ("Medium" if level < 6 else "Hard")
91
+ challenge_title = "Complete 1 quiz with 80%+"
92
+ challenge_desc = "Prove you remember yesterday's key points."
93
+ challenge_progress = 100 if quiz_score >= 80 else 0
94
+ reward = "+50 XP"
95
+ time_left = "Ends 11:59 PM"
96
+
97
+ # Achievements from real data
98
+ achievements = [
99
+ {"title": "First Steps", "desc": "Complete your first lesson", "earned": lessons_completed > 0},
100
+ {"title": "Quiz Whiz", "desc": "Score 80%+ on any quiz", "earned": quiz_score >= 80},
101
+ {"title": "On a Roll", "desc": "Study 3 days in a row", "earned": study_streak >= 3},
102
+ {"title": "Consistency", "desc": "Finish 5 assignments", "earned": total_lessons >= 5 and lessons_completed >= 5},
103
+ ]
104
+
105
+ # --- Welcome Card ---
106
+ st.markdown(
107
+ f"""
108
+ <div class="welcome-card">
109
+ <h2>Welcome back, {name}!</h2>
110
+ <p style="font-size: 20px;">{"Ready to continue your financial journey?" if lessons_completed > 0 else "Start your financial journey."}</p>
111
+ </div>
112
+ """,
113
+ unsafe_allow_html=True
114
+ )
115
+ st.write("")
116
+
117
+ # --- Quick Action Buttons ---
118
+ actions = [
119
+ ("📚 Start a Lesson", "Lessons"),
120
+ ("📝 Attempt a Quiz", "Quiz"),
121
+ ("💬 Talk to AI Tutor", "Chatbot"),
122
+ ]
123
+
124
+ # 5 columns: spacer, button, button, button, spacer
125
+ cols = st.columns([1, 2, 2, 2, 1])
126
+ for i, (label, page) in enumerate(actions):
127
+ with cols[i+1]: # skip the left spacer
128
+ if st.button(label, key=f"action_{i}"):
129
+ st.session_state.current_page = page
130
+ st.rerun()
131
+
132
+ st.write("")
133
+
134
+ # --- Progress Summary Cards ---
135
+ progress_cols = st.columns(3)
136
+ progress_cols[0].metric("📘 Lessons Completed", f"{lessons_completed}/{total_lessons}")
137
+ progress_cols[1].metric("📊 Quiz Score", f"{quiz_score}/100")
138
+ progress_cols[2].metric("🔥 Study Streak", f"{study_streak} days")
139
+ st.write("")
140
+
141
+ # --- XP Bar ---
142
+ # stats already fetched above
143
+ xp = int(stats.get("xp", 0))
144
+ level = int(stats.get("level", 1))
145
+ study_streak = int(stats.get("streak", 0))
146
+
147
+ # prefer server-provided per-level fields
148
+ into = int(stats.get("into", -1))
149
+ need = int(stats.get("need", -1))
150
+
151
+ # fallback if backend hasn't been updated to include into/need yet
152
+ if into < 0 or need <= 0:
153
+ base = 500
154
+ level = max(1, xp // base + 1)
155
+ start = (level - 1) * base
156
+ into = xp - start
157
+ need = base
158
+ if into == need:
159
+ level += 1
160
+ into = 0
161
+
162
+ cap = max(500, ((xp // 500) + 1) * 500) # next threshold
163
+ pct = 0 if cap <= 0 else min(100, int(round(100 * xp / cap)))
164
+
165
+ st.markdown(
166
+ f"""
167
+ <div class="xp-card">
168
+ <span class="xp-level">Level {level}</span>
169
+ <span class="xp-text">{xp:,} / {cap:,} XP</span>
170
+ <div class="xp-bar">
171
+ <div class="xp-fill" style="width: {pct}%;"></div>
172
+ </div>
173
+ <div class="xp-total">Total XP: {xp:,}</div>
174
+ </div>
175
+ """,
176
+ unsafe_allow_html=True
177
+ )
178
+ # pct = 0 if max_xp <= 0 else min(100, int(round((xp / max_xp) * 100)))
179
+ # st.markdown(
180
+ # f"""
181
+ # <div class="xp-card">
182
+ # <span class="xp-level">Level {level}</span>
183
+ # <span class="xp-text">{xp} / {max_xp} XP</span>
184
+ # <div class="xp-bar">
185
+ # <div class="xp-fill" style="width: {pct}%;"></div>
186
+ # </div>
187
+ # </div>
188
+ # """,
189
+ # unsafe_allow_html=True
190
+ # )
191
+ # st.write("")
192
+
193
+ # --- My Assignments (from DB) ---
194
+ st.markdown("---")
195
+ st.subheader("📘 My Work")
196
+ if not rows:
197
+ st.info("No assignments yet. Ask your teacher to assign a lesson.")
198
+ else:
199
+ for a in rows:
200
+ title = a.get("title", "Untitled")
201
+ subj = a.get("subject", "General")
202
+ lvl = a.get("level", "Beginner")
203
+ status = a.get("status", "not_started")
204
+ due = a.get("due_at")
205
+ due_txt = f" · Due {str(due)[:10]}" if due else ""
206
+ st.markdown(f"**{title}** · {subj} · {lvl}{due_txt}")
207
+ st.caption(f"Status: {status} · Resume at section {a.get('current_pos', 1)}")
208
+ st.markdown("---")
209
+
210
+ # --- Recent Lessons & Achievements ---
211
+ col1, col2 = st.columns(2)
212
+
213
+ def _progress_value(v):
214
+ try:
215
+ f = float(v)
216
+ except Exception:
217
+ return 0.0
218
+ # streamlit accepts 0–1 float; if someone passes 0–100, scale it
219
+ return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
220
+
221
+ with col1:
222
+ st.subheader("📖 Recent Lessons")
223
+ st.caption("Continue where you left off")
224
+ if not recent_lessons:
225
+ st.info("No recent lessons yet.")
226
+ else:
227
+ for lesson in recent_lessons:
228
+ prog = lesson.get("progress", 0)
229
+ st.progress(_progress_value(prog))
230
+ status = "✅ Complete" if (isinstance(prog, (int, float)) and prog >= 100) else f"{int(prog)}% complete"
231
+ st.write(f"**{lesson.get('title','Untitled Lesson')}** — {status}")
232
+
233
+ with col2:
234
+ st.subheader("🏆 Achievements")
235
+ st.caption("Your learning milestones")
236
+ for ach in achievements:
237
+ if ach["earned"]:
238
+ st.success(f"✔ {ach['title']} — {ach['desc']}")
239
+ else:
240
+ st.info(f"🔒 {ach['title']} — {ach['desc']}")
dashboards/teacher_db.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # dashboards/teacher_db.py
2
+ import os
3
+ import io
4
+ import csv
5
+ import datetime
6
+ import streamlit as st
7
+ import plotly.graph_objects as go
8
+
9
+ from utils import db as dbapi
10
+ import utils.api as api # backend Space client
11
+
12
+ # If DISABLE_DB=1 (default), don't call MySQL at all
13
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
14
+
15
+ def load_css(file_name):
16
+ try:
17
+ with open(file_name, 'r', encoding="utf-8") as f:
18
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
19
+ except FileNotFoundError:
20
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
21
+
22
+ def tile(icon, label, value):
23
+ return f"""
24
+ <div class="metric-card">
25
+ <div class="metric-icon">{icon}</div>
26
+ <div class="metric-value">{value}</div>
27
+ <div class="metric-label">{label}</div>
28
+ </div>
29
+ """
30
+
31
+ def _level_from_xp(xp: int) -> int:
32
+ """
33
+ Prefer backend/db helper if available, else simple fallback (every 500 XP = +1 level).
34
+ """
35
+ try:
36
+ if USE_LOCAL_DB and hasattr(dbapi, "level_from_xp"):
37
+ return int(dbapi.level_from_xp(xp))
38
+ if hasattr(api, "level_from_xp"): # if you add the endpoint
39
+ return int(api.level_from_xp(int(xp)))
40
+ except Exception:
41
+ pass
42
+ xp = int(xp or 0)
43
+ return 1 + (xp // 500)
44
+
45
+ def _safe_get_tiles(teacher_id: int) -> dict:
46
+ if USE_LOCAL_DB and hasattr(dbapi, "teacher_tiles"):
47
+ return dbapi.teacher_tiles(teacher_id)
48
+ try:
49
+ return api.teacher_tiles(teacher_id)
50
+ except Exception:
51
+ return {
52
+ "total_students": 0, "class_avg": 0.0,
53
+ "lessons_created": 0, "active_students": 0
54
+ }
55
+
56
+ def _safe_list_classes(teacher_id: int) -> list:
57
+ if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"):
58
+ return dbapi.list_classes_by_teacher(teacher_id)
59
+ try:
60
+ return api.list_classes_by_teacher(teacher_id)
61
+ except Exception:
62
+ return []
63
+
64
+ def _safe_create_class(teacher_id: int, name: str) -> dict:
65
+ if USE_LOCAL_DB and hasattr(dbapi, "create_class"):
66
+ return dbapi.create_class(teacher_id, name)
67
+ return api.create_class(teacher_id, name)
68
+
69
+ def _safe_class_student_metrics(class_id: int) -> list:
70
+ if USE_LOCAL_DB and hasattr(dbapi, "class_student_metrics"):
71
+ return dbapi.class_student_metrics(class_id)
72
+ try:
73
+ return api.class_student_metrics(class_id)
74
+ except Exception:
75
+ return []
76
+
77
+ def _safe_weekly_activity(class_id: int) -> list:
78
+ if USE_LOCAL_DB and hasattr(dbapi, "class_weekly_activity"):
79
+ return dbapi.class_weekly_activity(class_id)
80
+ try:
81
+ return api.class_weekly_activity(class_id)
82
+ except Exception:
83
+ return []
84
+
85
+ def _safe_progress_overview(class_id: int) -> dict:
86
+ if USE_LOCAL_DB and hasattr(dbapi, "class_progress_overview"):
87
+ return dbapi.class_progress_overview(class_id)
88
+ try:
89
+ return api.class_progress_overview(class_id)
90
+ except Exception:
91
+ return {
92
+ "overall_progress": 0.0, "quiz_performance": 0.0,
93
+ "lessons_completed": 0, "class_xp": 0
94
+ }
95
+
96
+ def _safe_recent_activity(class_id: int, limit=6, days=30) -> list:
97
+ if USE_LOCAL_DB and hasattr(dbapi, "class_recent_activity"):
98
+ return dbapi.class_recent_activity(class_id, limit=limit, days=days)
99
+ try:
100
+ return api.class_recent_activity(class_id, limit=limit, days=days)
101
+ except Exception:
102
+ return []
103
+
104
+ def _safe_list_students(class_id: int) -> list:
105
+ if USE_LOCAL_DB and hasattr(dbapi, "list_students_in_class"):
106
+ return dbapi.list_students_in_class(class_id)
107
+ try:
108
+ return api.list_students_in_class(class_id)
109
+ except Exception:
110
+ return []
111
+
112
+ def show_teacher_dashboard():
113
+ css_path = os.path.join("assets", "styles.css")
114
+ load_css(css_path)
115
+
116
+ user = st.session_state.user
117
+ teacher_id = user["user_id"]
118
+ name = user["name"]
119
+
120
+ # ========== HEADER / HERO ==========
121
+ colH1, colH2 = st.columns([5, 2])
122
+ with colH1:
123
+ st.markdown(f"""
124
+ <div class="header-container">
125
+ <div class="header-content">
126
+ <div class="header-left">
127
+ <div class="header-title">Welcome back, Teacher {name}!</div>
128
+ <div class="header-subtitle">Managing your classrooms</div>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ """, unsafe_allow_html=True)
133
+ with colH2:
134
+ with st.popover("➕ Create Classroom"):
135
+ new_class_name = st.text_input("Classroom Name", key="new_class_name")
136
+ if st.button("Create Classroom", key="create_classroom_btn"):
137
+ if new_class_name.strip():
138
+ try:
139
+ out = _safe_create_class(teacher_id, new_class_name.strip())
140
+ code = out.get("code") or out.get("class_code") or "—"
141
+ st.success(f"Classroom created. Code: **{code}**")
142
+ except Exception as e:
143
+ st.error(f"Create failed: {e}")
144
+
145
+ # ========== TILES ==========
146
+ tiles = _safe_get_tiles(teacher_id)
147
+ c1,c2,c3,c4 = st.columns(4)
148
+ c1.markdown(tile("👥","Total Students", tiles.get("total_students", 0)), unsafe_allow_html=True)
149
+ c2.markdown(tile("📊","Class Average", f"{int((tiles.get('class_avg') or 0)*100)}%"), unsafe_allow_html=True)
150
+ c3.markdown(tile("📚","Lessons Created", tiles.get("lessons_created", 0)), unsafe_allow_html=True)
151
+ c4.markdown(tile("📈","Active Students", tiles.get("active_students", 0)), unsafe_allow_html=True)
152
+
153
+ # ========== CLASS PICKER ==========
154
+ classes = _safe_list_classes(teacher_id)
155
+ if not classes:
156
+ st.info("No classes yet. Create one above, then share the code with students.")
157
+ return
158
+
159
+ idx = st.selectbox(
160
+ "Choose a class",
161
+ list(range(len(classes))),
162
+ index=0,
163
+ format_func=lambda i: f"{classes[i].get('name','Class')} (Code: {classes[i].get('code','')})"
164
+ )
165
+ selected = classes[idx]
166
+ class_id = selected.get("class_id") or selected.get("id")
167
+ class_code = selected.get("code","")
168
+
169
+ # secondary hero controls
170
+ cTop1, cTop2, cTop3 = st.columns([2,1,1])
171
+ with cTop1:
172
+ st.button(f"Class Code: {class_code}", disabled=True)
173
+ with cTop2:
174
+ if st.button("📋 Copy Code"):
175
+ st.toast("Code copied. Paste it anywhere your heart desires.")
176
+ with cTop3:
177
+ rows = _safe_class_student_metrics(class_id)
178
+ if rows:
179
+ headers = []
180
+ for r in rows:
181
+ for k in r.keys():
182
+ if k not in headers:
183
+ headers.append(k)
184
+ buf = io.StringIO()
185
+ writer = csv.DictWriter(buf, fieldnames=headers)
186
+ writer.writeheader()
187
+ for r in rows:
188
+ writer.writerow(r)
189
+ st.download_button(
190
+ "📤 Export Class Report",
191
+ data=buf.getvalue(),
192
+ file_name=f"class_{class_id}_report.csv",
193
+ mime="text/csv"
194
+ )
195
+ else:
196
+ st.button("📤 Export Class Report", disabled=True)
197
+
198
+ # ========== TOP ROW: WEEKLY ACTIVITY + CLASS PROGRESS ==========
199
+ left, right = st.columns([3,2])
200
+
201
+ with left:
202
+ st.subheader("Weekly Activity")
203
+ st.caption("Student engagement throughout the week")
204
+ activity = _safe_weekly_activity(class_id)
205
+ if activity:
206
+ days, lessons, quizzes, games = [], [], [], []
207
+ for row in activity:
208
+ date_str = row.get("date")
209
+ try:
210
+ # Support ISO date or datetime
211
+ day = datetime.datetime.fromisoformat(str(date_str)).strftime("%a")
212
+ except Exception:
213
+ day = str(date_str)
214
+ days.append(day)
215
+ lessons.append(row.get("lessons",0))
216
+ quizzes.append(row.get("quizzes",0))
217
+ games.append(row.get("games",0))
218
+ fig = go.Figure(data=[
219
+ go.Bar(name="Lessons", x=days, y=lessons),
220
+ go.Bar(name="Quizzes", x=days, y=quizzes),
221
+ go.Bar(name="Games", x=days, y=games),
222
+ ])
223
+ fig.update_layout(barmode="group", xaxis_title="Day", yaxis_title="Count")
224
+ st.plotly_chart(fig, use_container_width=True)
225
+ else:
226
+ st.info("No activity in the last 7 days.")
227
+
228
+ with right:
229
+ st.subheader("Class Progress Overview")
230
+ st.caption("How your students are performing")
231
+
232
+ prog = _safe_progress_overview(class_id)
233
+ overall_pct = int(round((prog.get("overall_progress") or 0) * 100))
234
+ quiz_pct = int(round((prog.get("quiz_performance") or 0) * 100))
235
+
236
+ st.text("Overall Progress")
237
+ st.progress(min(1.0, overall_pct/100.0))
238
+ st.caption(f"{overall_pct}%")
239
+
240
+ st.text("Quiz Performance")
241
+ st.progress(min(1.0, quiz_pct/100.0))
242
+ st.caption(f"{quiz_pct}%")
243
+
244
+ k1, k2 = st.columns(2)
245
+ k1.metric("📖 Lessons Completed", prog.get("lessons_completed", 0))
246
+ k2.metric("🪙 Total Class XP", prog.get("class_xp", 0))
247
+
248
+ # ========== BOTTOM ROW: RECENT ACTIVITY + QUICK ACTIONS ==========
249
+ b1, b2 = st.columns([3,2])
250
+
251
+ with b1:
252
+ st.subheader("Recent Student Activity")
253
+ st.caption("Latest activity from your students")
254
+ feed = _safe_recent_activity(class_id, limit=6, days=30)
255
+ if not feed:
256
+ st.caption("Nothing yet. Assign something, chief.")
257
+ else:
258
+ for r in feed:
259
+ kind = str(r.get("kind","")).lower()
260
+ icon = "📘" if kind == "lesson" else "🏆" if kind == "quiz" else "🎮"
261
+ lvl = r.get("level") or _level_from_xp(r.get("total_xp", 0))
262
+ tail = f" · {r['extra']}" if r.get("extra") else ""
263
+ st.write(f"{icon} **{r.get('student_name','(unknown)')}** — {r.get('item_title','(untitled)')}{tail} \n"
264
+ f"*Level {lvl}*")
265
+
266
+ with b2:
267
+ st.subheader("Quick Actions")
268
+ st.caption("Manage your classroom")
269
+ if st.button("📖 Create New Lesson", use_container_width=True):
270
+ st.session_state.current_page = "Content Management"
271
+ st.rerun()
272
+ if st.button("🏆 Create New Quiz", use_container_width=True):
273
+ st.session_state.current_page = "Content Management"
274
+ st.rerun()
275
+ if st.button("🗓️ Schedule Assignment", use_container_width=True):
276
+ st.session_state.current_page = "Class management"
277
+ st.rerun()
278
+ if st.button("📄 Generate Reports", use_container_width=True):
279
+ st.session_state.current_page = "Students List"
280
+ st.rerun()
281
+
282
+ # optional: keep your per-class expanders below
283
+ for c in classes:
284
+ with st.expander(f"{c.get('name','Class')} · Code **{c.get('code','')}**"):
285
+ st.write(f"Students: {c.get('total_students', 0)}")
286
+ avg = c.get("class_avg", 0.0)
287
+ st.write(f"Average score: {round(float(avg)*100) if avg is not None else 0}%")
288
+ roster = _safe_list_students(c.get("class_id") or c.get("id"))
289
+ if roster:
290
+ for s in roster:
291
+ lvl_slug = (s.get("level_slug") or s.get("level") or "beginner")
292
+ st.write(f"- {s.get('name','(unknown)')} · {s.get('email','—')} · Level {str(lvl_slug).capitalize()}")
293
+ else:
294
+ st.caption("No students yet. Share the code.")
isrgrootx1.pem ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -----BEGIN CERTIFICATE-----
2
+ MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
3
+ TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
4
+ cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
5
+ WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
6
+ ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
7
+ MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
8
+ h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
9
+ 0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
10
+ A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
11
+ T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
12
+ B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
13
+ B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
14
+ KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
15
+ OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
16
+ jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
17
+ qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
18
+ rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
19
+ HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
20
+ hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
21
+ ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
22
+ 3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
23
+ NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
24
+ ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
25
+ TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
26
+ jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
27
+ oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
28
+ 4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
29
+ mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
30
+ emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
31
+ -----END CERTIFICATE-----
phase/Student_view/chatbot.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Student_view/chatbot.py
2
+ import streamlit as st
3
+ import datetime, os, traceback
4
+ from huggingface_hub import InferenceClient
5
+
6
+ HF_TOKEN = os.getenv("HF_TOKEN")
7
+ GEN_MODEL = os.getenv("GEN_MODEL", "TinyLlama/TinyLlama-1.1B-Chat-v1.0") # <- default TinyLlama
8
+
9
+ if not HF_TOKEN:
10
+ st.error("⚠️ HF_TOKEN is not set. In your Space, add a Secret named HF_TOKEN.")
11
+ else:
12
+ client = InferenceClient(model=GEN_MODEL, token=HF_TOKEN, timeout=60)
13
+
14
+ TUTOR_PROMPT = (
15
+ "You are a kind Jamaican primary-school finance tutor. "
16
+ "Keep answers short, friendly, and age-appropriate. "
17
+ "Teach step-by-step with tiny examples. Avoid giving personal financial advice."
18
+ )
19
+
20
+
21
+
22
+ # -------------------------------
23
+ # History helpers
24
+ # -------------------------------
25
+ def _format_history_for_flan(messages: list[dict]) -> str:
26
+ """Format history for text-generation style models."""
27
+ lines = []
28
+ for m in messages:
29
+ txt = (m.get("text") or "").strip()
30
+ if not txt:
31
+ continue
32
+ lines.append(("Tutor" if m.get("sender") == "assistant" else "User") + f": {txt}")
33
+ return "\n".join(lines)
34
+
35
+ def _history_as_chat_messages(messages: list[dict]) -> list[dict]:
36
+ """Convert history to chat-completion style messages."""
37
+ msgs = [{"role": "system", "content": TUTOR_PROMPT}]
38
+ for m in messages:
39
+ txt = (m.get("text") or "").strip()
40
+ if not txt:
41
+ continue
42
+ role = "assistant" if m.get("sender") == "assistant" else "user"
43
+ msgs.append({"role": role, "content": txt})
44
+ return msgs
45
+
46
+ def _extract_chat_text(chat_resp) -> str:
47
+ """Extract text from HF chat response."""
48
+ try:
49
+ return chat_resp.choices[0].message["content"] if isinstance(
50
+ chat_resp.choices[0].message, dict
51
+ ) else chat_resp.choices[0].message.content
52
+ except Exception:
53
+ try:
54
+ return chat_resp["choices"][0]["message"]["content"]
55
+ except Exception:
56
+ return str(chat_resp)
57
+
58
+ # -------------------------------
59
+ # Reply logic
60
+ # -------------------------------
61
+ def _reply_with_hf():
62
+ if "client" not in globals():
63
+ raise RuntimeError("HF client not initialized")
64
+
65
+ try:
66
+ # 1) Prefer chat API
67
+ msgs = _history_as_chat_messages(st.session_state.get("messages", []))
68
+ chat = client.chat.completions.create(
69
+ model=GEN_MODEL,
70
+ messages=msgs,
71
+ max_tokens=300, # give enough room
72
+ temperature=0.2,
73
+ top_p=0.9,
74
+ )
75
+ return _extract_chat_text(chat).strip()
76
+
77
+ except ValueError as ve:
78
+ # 2) Fallback to text-generation if chat unsupported
79
+ if "Supported task: text-generation" in str(ve):
80
+ convo = _format_history_for_flan(st.session_state.get("messages", []))
81
+ tg_prompt = f"{TUTOR_PROMPT}\n\n{convo}\n\nTutor:"
82
+ resp = client.text_generation(
83
+ tg_prompt,
84
+ max_new_tokens=300,
85
+ temperature=0.2,
86
+ top_p=0.9,
87
+ repetition_penalty=1.1,
88
+ return_full_text=True,
89
+ stream=False,
90
+ )
91
+ return (resp.get("generated_text") if isinstance(resp, dict) else resp).strip()
92
+
93
+ raise # rethrow anything else
94
+
95
+ except Exception as e:
96
+ err_text = ''.join(traceback.format_exception_only(type(e), e)).strip()
97
+ raise RuntimeError(f"Hugging Face API Error: {err_text}")
98
+
99
+ # -------------------------------
100
+ # Session message helper
101
+ # -------------------------------
102
+ def add_message(text: str, sender: str):
103
+ if "messages" not in st.session_state:
104
+ st.session_state.messages = []
105
+ st.session_state.messages.append(
106
+ {
107
+ "id": str(datetime.datetime.now().timestamp()),
108
+ "text": text,
109
+ "sender": sender,
110
+ "timestamp": datetime.datetime.now()
111
+ }
112
+ )
113
+
114
+ def _coerce_ts(ts):
115
+ if isinstance(ts, datetime.datetime):
116
+ return ts
117
+ if isinstance(ts, (int, float)):
118
+ try:
119
+ return datetime.datetime.fromtimestamp(ts)
120
+ except Exception:
121
+ return None
122
+ if isinstance(ts, str):
123
+ # Try ISO 8601 first; fall back to float epoch
124
+ try:
125
+ return datetime.datetime.fromisoformat(ts)
126
+ except Exception:
127
+ try:
128
+ return datetime.datetime.fromtimestamp(float(ts))
129
+ except Exception:
130
+ return None
131
+ return None
132
+
133
+ def _normalize_messages():
134
+ msgs = st.session_state.get("messages", [])
135
+ normed = []
136
+ now = datetime.datetime.now()
137
+ for m in msgs:
138
+ text = (m.get("text") or "").strip()
139
+ sender = m.get("sender") or "user"
140
+ ts = _coerce_ts(m.get("timestamp")) or now
141
+ normed.append({**m, "text": text, "sender": sender, "timestamp": ts})
142
+ st.session_state.messages = normed
143
+
144
+
145
+ # -------------------------------
146
+ # Streamlit page
147
+ # -------------------------------
148
+ def show_page():
149
+ st.title("🤖 AI Financial Tutor")
150
+ st.caption("Get personalized help with your financial questions")
151
+
152
+ if "messages" not in st.session_state:
153
+ st.session_state.messages = [{
154
+ "id": "1",
155
+ "text": "Hi! I'm your AI Financial Tutor. What would you like to learn today?",
156
+ "sender": "assistant",
157
+ "timestamp": datetime.datetime.now()
158
+ }]
159
+ if "is_typing" not in st.session_state:
160
+ st.session_state.is_typing = False
161
+
162
+ _normalize_messages()
163
+
164
+ chat_container = st.container()
165
+ with chat_container:
166
+ for msg in st.session_state.messages:
167
+ time_str = msg["timestamp"].strftime("%H:%M") if hasattr(msg["timestamp"], "strftime") else datetime.datetime.now().strftime("%H:%M")
168
+ bubble = (
169
+ f"<div style='background-color:#e0e0e0; color:black; padding:10px; border-radius:12px; max-width:70%; margin-bottom:5px;'>"
170
+ f"{msg.get('text','')}<br><sub>{time_str}</sub></div>"
171
+ if msg.get("sender") == "assistant" else
172
+ f"<div style='background-color:#4CAF50; color:white; padding:10px; border-radius:12px; max-width:70%; margin-left:auto; margin-bottom:5px;'>"
173
+ f"{msg.get('text','')}<br><sub>{time_str}</sub></div>"
174
+ )
175
+ st.markdown(bubble, unsafe_allow_html=True)
176
+
177
+
178
+ if st.session_state.is_typing:
179
+ st.markdown("🤖 _FinanceBot is typing..._")
180
+
181
+ if len(st.session_state.messages) == 1:
182
+ st.markdown("Try asking about:")
183
+ cols = st.columns(2)
184
+ quick = [
185
+ "How does compound interest work?",
186
+ "How much should I save for emergencies?",
187
+ "What's a good budgeting strategy?",
188
+ "How do I start investing?"
189
+ ]
190
+ for i, q in enumerate(quick):
191
+ if cols[i % 2].button(q):
192
+ add_message(q, "user")
193
+ st.session_state.is_typing = True
194
+ st.rerun()
195
+
196
+ user_input = st.chat_input("Ask me anything about personal finance...")
197
+ if user_input:
198
+ add_message(user_input, "user")
199
+ st.session_state.is_typing = True
200
+ st.rerun()
201
+
202
+ if st.session_state.is_typing:
203
+ try:
204
+ with st.spinner("FinanceBot is thinking..."):
205
+ bot_reply = _reply_with_hf()
206
+ add_message(bot_reply, "assistant")
207
+ except Exception as e:
208
+ add_message(f"⚠️ Error: {e}", "assistant")
209
+ finally:
210
+ st.session_state.is_typing = False
211
+ st.rerun()
212
+
213
+ if st.button("Back to Dashboard", key="ai_tutor_back_btn"):
214
+ st.session_state.current_page = "Student Dashboard"
215
+ st.rerun()
phase/Student_view/game.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils import db as dbapi
3
+ import os
4
+ import utils.api as api
5
+
6
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
7
+
8
+ # --- Load external CSS (optional) ---
9
+ def load_css(file_name: str):
10
+ try:
11
+ with open(file_name, "r", encoding="utf-8") as f:
12
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
13
+ except FileNotFoundError:
14
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
15
+
16
+
17
+ st.session_state.setdefault("current_game", None)
18
+
19
+
20
+ # --- GAME RENDERERS ---
21
+ def _render_budget_builder():
22
+ try:
23
+ from phase.Student_view.games import budgetbuilder as budget_module
24
+ except Exception as e:
25
+ st.error(f"Couldn't import Budget Builder module: {e}")
26
+ return
27
+
28
+ if hasattr(budget_module, "show_budget_builder"):
29
+ budget_module.show_budget_builder()
30
+ elif hasattr(budget_module, "show_page"):
31
+ budget_module.show_page()
32
+ else:
33
+ st.error("Budget Builder module found, but no show function (show_budget_builder/show_page).")
34
+
35
+ def _render_debt_dilemma():
36
+ try:
37
+ from phase.Student_view.games import debtdilemma as debt_module
38
+ except Exception as e:
39
+ st.error(f"Couldn't import Debt Dilemma module: {e}")
40
+ return
41
+
42
+ if hasattr(debt_module, "show_debt_dilemma"):
43
+ debt_module.show_debt_dilemma()
44
+ elif hasattr(debt_module, "show_page"):
45
+ debt_module.show_page()
46
+ else:
47
+ st.error("Debt Dilemma module found, but no show function (show_debt_dilemma/show_page).")
48
+
49
+ def _render_money_match():
50
+ """
51
+ Renders Money Match if the file exists at phase/games/MoneyMatch.py
52
+ and exposes a show_page() function.
53
+ """
54
+ try:
55
+
56
+ from phase.Student_view.games import MoneyMatch as mm_module
57
+ except Exception as e:
58
+ st.error(f"Couldn't import Money Match module: {e}")
59
+ st.info("Tip: ensure phase/games/MoneyMatch.py exists and defines show_page()")
60
+ return
61
+
62
+ if hasattr(mm_module, "show_page"):
63
+ mm_module.show_page()
64
+ else:
65
+ st.error("Money Match module found, but no show_page() function.")
66
+
67
+ #render for profit puzzle
68
+ def _render_profit_puzzle():
69
+ try:
70
+ from phase.Student_view.games import profitpuzzle as pp_module
71
+ except Exception as e:
72
+ st.error(f"Couldn't import Profit Puzzle module: {e}")
73
+ return
74
+
75
+ if hasattr(pp_module, "show_profit_puzzle"):
76
+ pp_module.show_profit_puzzle()
77
+ elif hasattr(pp_module, "show_page"):
78
+ pp_module.show_page()
79
+ else:
80
+ st.error("Profit Puzzle module found, but no show function (show_profit_puzzle/show_page).")
81
+
82
+
83
+ import textwrap
84
+
85
+ def render_leaderboard(leaderboard):
86
+ def rank_symbol(rank):
87
+ if rank == "You":
88
+ return "🟢"
89
+ if isinstance(rank, int):
90
+ return "🥇" if rank == 1 else "🥈" if rank == 2 else "🥉" if rank == 3 else f"#{rank}"
91
+ return str(rank)
92
+
93
+ def rank_medal_class(rank):
94
+ if isinstance(rank, int) and rank in (1, 2, 3):
95
+ return f"medal-{rank}"
96
+ return ""
97
+
98
+ rows = []
99
+ head = '<div class="lb-head">🏆 Leaderboard</div>'
100
+ for p in leaderboard:
101
+ is_you = p["rank"] == "You"
102
+ medal_cls = rank_medal_class(p["rank"])
103
+ symbol = rank_symbol(p["rank"])
104
+ you_pill = '<span class="lb-you-pill">YOU</span>' if is_you else ""
105
+ rows.append(
106
+ textwrap.dedent(f"""
107
+ <div class="lb-row {'is-you' if is_you else ''}">
108
+ <div class="lb-rank {medal_cls}">{symbol}</div>
109
+ <div class="lb-name">{p['name']}</div>
110
+ <div class="lb-level">Lvl {p['level']}</div>
111
+ <div class="lb-xp">{p['xp']:,} XP</div>
112
+ {you_pill}
113
+ </div>
114
+ """).strip()
115
+ )
116
+
117
+ html = textwrap.dedent(f"""
118
+ <div class="leaderboard">
119
+ {head}
120
+ {''.join(rows)}
121
+ </div>
122
+ """).strip()
123
+
124
+ st.markdown(html, unsafe_allow_html=True)
125
+
126
+ def _load_leaderboard(user_id: int, limit: int = 10) -> list[dict]:
127
+ you_name = (st.session_state.get("user") or {}).get("name") or "You"
128
+ class_id = st.session_state.get("current_class_id")
129
+ rows: list[dict] = []
130
+
131
+ try:
132
+ if USE_LOCAL_DB:
133
+ # ---------- local DB path ----------
134
+ if not class_id and hasattr(dbapi, "list_classes_for_student"):
135
+ classes = dbapi.list_classes_for_student(user_id) or []
136
+ if classes:
137
+ class_id = classes[0]["class_id"]
138
+ st.session_state.current_class_id = class_id
139
+
140
+ if class_id and hasattr(dbapi, "leaderboard_for_class"):
141
+ rows = dbapi.leaderboard_for_class(class_id, limit=limit) or []
142
+ elif hasattr(dbapi, "leaderboard_global"):
143
+ rows = dbapi.leaderboard_global(limit=limit) or []
144
+ elif class_id and hasattr(dbapi, "class_student_metrics"):
145
+ metrics = dbapi.class_student_metrics(class_id) or []
146
+ rows = [{
147
+ "user_id": m.get("student_id"),
148
+ "name": m.get("name") or m.get("email") or "Student",
149
+ "xp": int(m.get("total_xp", 0)),
150
+ "level": dbapi.level_from_xp(int(m.get("total_xp", 0))),
151
+ } for m in metrics]
152
+
153
+ else:
154
+ # ---------- backend API path (DISABLE_DB=1) ----------
155
+ # 1) pick a class for the logged-in student
156
+ if not class_id:
157
+ try:
158
+ classes = api.list_classes_for_student(user_id) or []
159
+ except Exception:
160
+ classes = []
161
+ if classes:
162
+ class_id = classes[0].get("class_id")
163
+ st.session_state.current_class_id = class_id
164
+
165
+ if class_id:
166
+ # 2) get roster
167
+ try:
168
+ roster = api.list_students_in_class(class_id) or []
169
+ except Exception:
170
+ roster = []
171
+
172
+ # 3) for each student, pull stats (XP/level)
173
+ rows = []
174
+ for s in roster:
175
+ sid = s.get("user_id") or s.get("student_id")
176
+ if not sid:
177
+ continue
178
+ try:
179
+ stt = api.user_stats(int(sid)) or {}
180
+ except Exception:
181
+ stt = {}
182
+ rows.append({
183
+ "user_id": int(sid),
184
+ "name": s.get("name") or s.get("email") or "Student",
185
+ "xp": int(stt.get("xp", 0)),
186
+ "level": int(stt.get("level", 1)),
187
+ })
188
+ else:
189
+ # No class available; at least show the current user
190
+ try:
191
+ s = api.user_stats(user_id) or {}
192
+ except Exception:
193
+ s = {}
194
+ rows = [{"user_id": user_id, "name": you_name,
195
+ "xp": int(s.get("xp", 0)), "level": int(s.get("level", 1))}]
196
+ except Exception:
197
+ rows = []
198
+
199
+ # Ensure YOU is present
200
+ if not any(r.get("user_id") == user_id for r in rows):
201
+ rows.append({"user_id": user_id, "name": you_name, "xp": 0, "level": 1})
202
+
203
+ # Rank, mark YOU, put YOU first
204
+ rows.sort(key=lambda r: int(r.get("xp", 0)), reverse=True)
205
+ ranked = []
206
+ for i, r in enumerate(rows, start=1):
207
+ ranked.append({
208
+ "rank": i,
209
+ "user_id": r["user_id"],
210
+ "name": r["name"],
211
+ "level": int(r["level"]),
212
+ "xp": int(r["xp"]),
213
+ })
214
+ for r in ranked:
215
+ if r["user_id"] == user_id:
216
+ r["rank"] = "You"
217
+ break
218
+ you = [r for r in ranked if r["rank"] == "You"]
219
+ others = [r for r in ranked if r["rank"] != "You"]
220
+ return (you + others)[:limit]
221
+
222
+
223
+
224
+
225
+ # --- MAIN GAMES HUB & ROUTER ---
226
+ def show_games():
227
+ load_css(os.path.join("assets", "styles.css"))
228
+
229
+ if "user" not in st.session_state or st.session_state.user is None:
230
+ st.error("❌ Please login first.")
231
+ st.session_state.current_page = "Welcome"
232
+ st.rerun()
233
+
234
+ game_key = st.session_state.current_game
235
+
236
+ # If a specific game is active → render it
237
+ if game_key is not None:
238
+ if game_key == "budget_builder":
239
+ _render_budget_builder()
240
+ elif game_key == "money_match":
241
+ _render_money_match()
242
+ elif game_key == "debt_dilemma":
243
+ _render_debt_dilemma()
244
+ elif game_key == "profit_puzzle":
245
+ _render_profit_puzzle()
246
+
247
+ st.markdown("---")
248
+ if st.button("⬅ Back to Games Hub"):
249
+ st.session_state.current_game = None
250
+ st.rerun()
251
+ return # don’t render the hub
252
+
253
+ # ===== Games Hub =====
254
+ st.title("Financial Games")
255
+ st.subheader("Learn by playing! Master financial concepts through interactive games.")
256
+
257
+ # Progress overview
258
+ col1, col2 = st.columns([1, 5])
259
+ with col1:
260
+ st.markdown(
261
+ """
262
+ <div style="
263
+ width:50px; height:50px;
264
+ border-radius:15px;
265
+ background: linear-gradient(135deg, #22c55e, #059669);
266
+ display:flex; align-items:center; justify-content:center;
267
+ font-size:28px;
268
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
269
+ ">
270
+
271
+ </div>
272
+ """,
273
+ unsafe_allow_html=True
274
+ )
275
+
276
+ with col2:
277
+ # pull live XP/level
278
+ user_id = st.session_state.user["user_id"]
279
+
280
+ # Prefer local DB only if enabled. Otherwise call backend.
281
+ if USE_LOCAL_DB and hasattr(dbapi, "user_xp_and_level"):
282
+ stats = dbapi.user_xp_and_level(user_id) # {'xp', 'level', 'streak', maybe 'into','need'}
283
+ else:
284
+ try:
285
+ stats = api.user_stats(user_id) # backend /students/{id}/stats
286
+ except Exception as e:
287
+ # hard fallback so the page still renders
288
+ stats = {"xp": int(st.session_state.get("xp", 0)), "level": 1, "streak": 0}
289
+
290
+ total_xp = int(stats.get("xp", 0))
291
+ level = int(stats.get("level", 1))
292
+ st.session_state.xp = total_xp
293
+ st.session_state.streak = int(stats.get("streak", 0))
294
+
295
+ # Show progress as TOTAL XP toward the NEXT threshold
296
+ base = 500
297
+ # keep the server's level if it is sane, otherwise recompute
298
+ level = level if level >= 1 else max(1, total_xp // base + 1)
299
+
300
+ cap = level * base # Level 1 -> 500, Level 2 -> 1000, etc.
301
+ progress_pct = min(100, int(round((total_xp / cap) * 100)))
302
+
303
+ st.write(f"Level {level} Experience Points")
304
+ st.markdown(f"""
305
+ <div style="background:#e0e0e0;border-radius:12px;padding:3px;width:100%;">
306
+ <div style="
307
+ width:{progress_pct}%;
308
+ background:linear-gradient(135deg,#22c55e,#059669);
309
+ height:24px;border-radius:10px;text-align:right;
310
+ color:white;font-weight:bold;padding-right:8px;line-height:24px;">
311
+ {total_xp:,} / {cap:,} XP
312
+ </div>
313
+ </div>
314
+ <div style="font-size:12px;color:#6b7280;margin-top:6px;">Total XP: {total_xp:,}</div>
315
+ """, unsafe_allow_html=True)
316
+
317
+ st.markdown("---")
318
+
319
+ # Game list
320
+ games = [
321
+ {"key": "money_match", "icon": "💰", "title": "Money Match",
322
+ "description": "Drag coins and notes to match target values. Perfect for learning denominations!",
323
+ "difficulty": "Easy", "xp": "+10 XP", "time": "5-10 min", "color": "linear-gradient(135deg, #22c55e, #059669)"},
324
+ {"key": "budget_builder", "icon": "📊", "title": "Budget Builder",
325
+ "description": "Allocate your weekly allowance across different spending categories with real-time pie charts.",
326
+ "difficulty": "Easy", "xp": "+50 XP", "time": "10-15 min", "color": "linear-gradient(135deg, #3b82f6, #06b6d4)"},
327
+ {"key": "profit_puzzle", "icon": "🧩", "title": "Profit Puzzle",
328
+ "description": "Solve business scenarios with interactive sliders. Calculate profit, revenue, and costs!",
329
+ "difficulty": "Medium", "xp": "+150 XP", "time": "10-15 min", "color": "linear-gradient(135deg, #6366f1, #8b5cf6)"},
330
+ {"key": "debt_dilemma", "icon": "⚠️", "title": "Debt Dilemma",
331
+ "description": "Experience borrowing JA$100 and learn about interest, repayment schedules, and credit scores.",
332
+ "difficulty": "Hard", "xp": "+200 XP", "time": "20-25 min", "color": "linear-gradient(135deg, #f97316, #dc2626)"},
333
+ ]
334
+
335
+ cols = st.columns(2)
336
+ color_map = {"Easy": "green", "Medium": "orange", "Hard": "red"}
337
+
338
+ for i, g in enumerate(games):
339
+ with cols[i % 2]:
340
+ st.markdown(
341
+ f"""
342
+ <div style="
343
+ width:60px; height:60px;
344
+ border-radius:16px;
345
+ background:{g['color']};
346
+ display:flex; align-items:center; justify-content:center;
347
+ font-size:28px; margin-bottom:10px;
348
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
349
+ ">
350
+ {g['icon']}
351
+ </div>
352
+ """,
353
+ unsafe_allow_html=True
354
+ )
355
+ st.subheader(g["title"])
356
+ st.write(g["description"])
357
+ diff_color = color_map.get(g["difficulty"], "gray")
358
+ st.markdown(
359
+ f"<div style='color:{diff_color}; font-weight:bold'>{g['difficulty']}</div> | "
360
+ f"{g['xp']} | {g['time']}",
361
+ unsafe_allow_html=True
362
+ )
363
+ if st.button("▶ Play Now", key=f"play_{g['key']}"):
364
+ st.session_state.current_game = g["key"]
365
+ st.rerun()
366
+
367
+
368
+ st.markdown("---")
369
+
370
+ # Leaderboard & Tips
371
+ col_leader, col_tips = st.columns(2)
372
+ with col_leader:
373
+ user_id = st.session_state.user["user_id"]
374
+ lb = _load_leaderboard(user_id, limit=10)
375
+ if lb:
376
+ render_leaderboard(lb)
377
+ else:
378
+ st.info("No leaderboard data yet.")
379
+
380
+
381
+
382
+
383
+ with col_tips:
384
+ st.subheader("Game Tips")
385
+ for tip in [
386
+ "🌟 Start with easier games to build confidence",
387
+ "⏰ Take your time to understand concepts",
388
+ "🏆 Replay games to improve your score",
389
+ "🌍 Apply game lessons to real life",
390
+ ]:
391
+ st.markdown(f"- {tip}")
phase/Student_view/games/MoneyMatch.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase\Student_view\games\MoneyMatch.py
2
+
3
+ import time
4
+ import os
5
+ import random
6
+ from pathlib import Path
7
+ import streamlit as st
8
+ from utils import db as dbapi
9
+ import time
10
+ from utils import db as db_util
11
+ from utils import api
12
+
13
+
14
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
15
+
16
+
17
+ # ---------- paths ----------
18
+ PROJECT_ROOT = Path(__file__).resolve().parents[3]
19
+
20
+ def _asset(*parts: str) -> str:
21
+ # JMD image path
22
+ return str((PROJECT_ROOT / "assets" / "images" / Path(*parts)).resolve())
23
+
24
+ def _safe_image(path: str, *, caption: str = ""):
25
+ if not os.path.exists(path):
26
+ st.warning(f"Image not found: {Path(path).name}. Button still works.")
27
+ return False
28
+ st.image(path, use_container_width=True, caption=caption)
29
+ return True
30
+
31
+ # ---------- state helpers ----------
32
+ def _init_state():
33
+ ss = st.session_state
34
+ if "mm_level" not in ss: ss.mm_level = 1
35
+ if "mm_xp" not in ss: ss.mm_xp = 0
36
+ if "mm_matches" not in ss: ss.mm_matches = 0
37
+ if "mm_target" not in ss: ss.mm_target = random.randint(7, 10000) # randon goal generator
38
+ if "mm_selected" not in ss: ss.mm_selected = []
39
+ if "mm_total" not in ss: ss.mm_total = 0
40
+ if "mm_start_ts" not in ss: ss.mm_start_ts = time.perf_counter()
41
+ if "mm_saved" not in ss: ss.mm_saved = False
42
+
43
+ def _reset_round(new_target: int | None = None):
44
+ ss = st.session_state
45
+ ss.mm_selected = []
46
+ ss.mm_total = 0
47
+ ss.mm_target = new_target if new_target is not None else random.randint(7, 10000)
48
+ ss.mm_start_ts = time.perf_counter()
49
+ ss.mm_saved = False
50
+
51
+ def _award_xp(gained: int):
52
+ ss = st.session_state
53
+ ss.mm_xp += gained
54
+ ss.mm_matches += 1
55
+ while ss.mm_xp >= ss.mm_level * 100:
56
+ ss.mm_level += 1
57
+
58
+ def _persist_success(gained_xp: int):
59
+ user = st.session_state.get("user") or {}
60
+ user_id = int(user.get("user_id", 0))
61
+ if not user_id:
62
+ st.error("Not saving. No logged-in user_id in session.")
63
+ return
64
+
65
+ payload = dict(
66
+ user_id=user_id,
67
+ target=int(st.session_state.mm_target),
68
+ total=int(st.session_state.mm_total),
69
+ elapsed_ms=int((time.perf_counter() - st.session_state.mm_start_ts) * 1000),
70
+ matched=True,
71
+ gained_xp=int(gained_xp),
72
+ )
73
+
74
+ try:
75
+ if USE_LOCAL_DB and hasattr(dbapi, "record_money_match_play"):
76
+ # direct DB mode
77
+ dbapi.record_money_match_play(**payload)
78
+ st.toast(f"Saved to DB +{gained_xp} XP")
79
+ else:
80
+ # backend mode (DISABLE_DB=1)
81
+ api.record_money_match_play(**payload)
82
+ st.toast(f"Saved via backend +{gained_xp} XP")
83
+ st.session_state.mm_saved = True
84
+ except Exception as e:
85
+ st.error(f"Save failed: {e}")
86
+
87
+
88
+ # --- CSS injection (run every render) ---
89
+ def _inject_css():
90
+ css_path = PROJECT_ROOT / "assets" / "styles.css"
91
+ try:
92
+ css = css_path.read_text(encoding="utf-8")
93
+ st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
94
+ except Exception:
95
+ # don't crash the page because of styling
96
+ pass
97
+
98
+
99
+ # ---------- denominations ----------
100
+ DENOMS = [
101
+ ("JA$1", 1, _asset("jmd", "jmd_1.jpeg")),
102
+ ("JA$5", 5, _asset("jmd", "jmd_5.jpeg")),
103
+ ("JA$10", 10, _asset("jmd", "jmd_10.jpeg")),
104
+ ("JA$20", 20, _asset("jmd", "jmd_20.jpeg")),
105
+ ("JA$50", 50, _asset("jmd", "jmd_50.jpg")),
106
+ ("JA$100", 100, _asset("jmd", "jmd_100.jpg")),
107
+ ("JA$500", 500, _asset("jmd", "jmd_500.jpg")),
108
+ ("JA$1000", 1000, _asset("jmd", "jmd_1000.jpeg")),
109
+ ("JA$2000", 2000, _asset("jmd", "jmd_2000.jpeg")),
110
+ ("JA$5000", 5000, _asset("jmd", "jmd_5000.jpeg")),
111
+ ]
112
+
113
+ # ---------- main ----------
114
+ def show_page():
115
+ _init_state()
116
+ _inject_css() # <- keep this here so it runs on every rerun
117
+ ss = st.session_state
118
+
119
+
120
+ if st.button("← Back to Games"):
121
+ ss.current_game = None
122
+ st.rerun()
123
+
124
+ st.title("Money Match Challenge")
125
+
126
+ left, right = st.columns([1.75, 1])
127
+
128
+ with left:
129
+ st.markdown('<div class="mm-card">', unsafe_allow_html=True)
130
+ st.markdown(f"<h3>Target: <span class='mm-target'>JA${ss.mm_target}</span></h3>", unsafe_allow_html=True)
131
+ st.markdown(f"<div class='mm-total'>JA${ss.mm_total}</div>", unsafe_allow_html=True)
132
+
133
+ ratio = min(ss.mm_total / ss.mm_target, 1.0) if ss.mm_target else 0
134
+ st.progress(ratio)
135
+
136
+ diff = ss.mm_target - ss.mm_total
137
+ need_text = "Perfect match. Click Next round." if diff == 0 else (f"Need JA${diff} more" if diff > 0 else f"Overshot by JA${abs(diff)}")
138
+ st.caption(need_text)
139
+
140
+ # autosave stats if perfect match
141
+ if diff == 0 and not ss.mm_saved:
142
+ gained = 10
143
+ _persist_success(gained)
144
+ _award_xp(gained)
145
+ ss.mm_saved = True
146
+
147
+ # tray
148
+ if ss.mm_selected:
149
+ chips = " ".join([f"<span class='mm-chip'>${v}</span>" for v in ss.mm_selected])
150
+ st.markdown(f"<div class='mm-tray'>{chips}</div>", unsafe_allow_html=True)
151
+ else:
152
+ st.markdown("<div class='mm-tray mm-empty'>Selected money will appear here</div>", unsafe_allow_html=True)
153
+
154
+ c1, c2 = st.columns([1,1])
155
+ with c1:
156
+ if st.button("⟲ Reset"):
157
+ _reset_round(ss.mm_target)
158
+ st.rerun()
159
+ with c2:
160
+ if ss.mm_total == ss.mm_target:
161
+ if st.button("Next round ▶"):
162
+ gained = 10
163
+ # avoid double insert when autosave
164
+ if not ss.mm_saved:
165
+ _persist_success(gained)
166
+ _award_xp(gained)
167
+ _reset_round()
168
+ st.rerun()
169
+
170
+ st.markdown("</div>", unsafe_allow_html=True)
171
+
172
+ # Money Collection
173
+ st.markdown("<h4>Money Collection</h4>", unsafe_allow_html=True)
174
+ grid_cols = st.columns(4)
175
+ for i, (label, value, img) in enumerate(DENOMS):
176
+ with grid_cols[i % 4]:
177
+ _safe_image(img, caption=label)
178
+ if st.button(label, key=f"mm_add_{value}"):
179
+ ss.mm_selected.append(value)
180
+ ss.mm_total += value
181
+ st.rerun()
182
+
183
+ with right:
184
+ st.markdown(
185
+ f"""
186
+ <div class="mm-side-card">
187
+ <h4>🏆 Stats</h4>
188
+ <div class="mm-metric"><span>Current Level</span><b>{ss.mm_level}</b></div>
189
+ <div class="mm-metric"><span>Total XP</span><b>{ss.mm_xp}</b></div>
190
+ <div class="mm-metric"><span>Matches Made</span><b>{ss.mm_matches}</b></div>
191
+ </div>
192
+ """,
193
+ unsafe_allow_html=True,
194
+ )
195
+ st.markdown(
196
+ """
197
+ <div class="mm-side-card">
198
+ <h4>How to Play</h4>
199
+ <ol class="mm-howto">
200
+ <li>Look at the target amount</li>
201
+ <li>Click coins and notes to add them</li>
202
+ <li>Match the target exactly to earn XP</li>
203
+ <li>Level up with each successful match</li>
204
+ </ol>
205
+ </div>
206
+ """,
207
+ unsafe_allow_html=True,
208
+ )
phase/Student_view/games/budgetbuilder.py ADDED
@@ -0,0 +1,565 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase\Student_view\games\budgetbuilder.py
2
+
3
+ import streamlit as st
4
+ import os, time
5
+ from utils import api as backend
6
+ from utils import db as dbapi
7
+
8
+ DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
9
+
10
+ def _rerun():
11
+ try:
12
+ st.rerun()
13
+ except AttributeError:
14
+ st.experimental_rerun()
15
+
16
+
17
+ def _refresh_global_xp():
18
+ user = st.session_state.get("user")
19
+ if not user:
20
+ return
21
+ try:
22
+ stats = backend.user_stats(user["user_id"]) if DISABLE_DB else dbapi.user_xp_and_level(user["user_id"])
23
+ st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
24
+ st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
25
+ except Exception as e:
26
+ st.warning(f"XP refresh failed: {e}")
27
+
28
+
29
+ def _persist_budget_result(level_cfg: dict, success: bool, gained_xp: int):
30
+ user = st.session_state.get("user")
31
+ if not user:
32
+ st.info("Login to earn and save XP.")
33
+ return
34
+
35
+ try:
36
+ elapsed_ms = int((time.time() - st.session_state.get("bb_start_ts", time.time())) * 1000)
37
+ allocations = [{"id": cid, "amount": int(val)} for cid, val in st.session_state.categories.items()]
38
+ budget_score = 100 if success else 0
39
+
40
+ if DISABLE_DB:
41
+ backend.record_budget_builder_play(
42
+ user_id=user["user_id"],
43
+ weekly_allowance=int(level_cfg["income"]),
44
+ budget_score=int(budget_score),
45
+ elapsed_ms=elapsed_ms,
46
+ allocations=allocations,
47
+ gained_xp=int(gained_xp),
48
+ )
49
+ else:
50
+ # Local DB path (if your db layer has one of these)
51
+ if hasattr(dbapi, "record_budget_builder_result"):
52
+ dbapi.record_budget_builder_result(
53
+ user_id=user["user_id"],
54
+ weekly_allowance=int(level_cfg["income"]),
55
+ budget_score=int(budget_score),
56
+ elapsed_ms=elapsed_ms,
57
+ allocations=allocations,
58
+ gained_xp=int(gained_xp),
59
+ )
60
+ elif hasattr(dbapi, "award_xp"):
61
+ dbapi.award_xp(user["user_id"], int(gained_xp), reason="budget_builder")
62
+
63
+ _refresh_global_xp() # <-- this makes the XP bar move immediately
64
+ except Exception as e:
65
+ st.warning(f"Could not save budget result: {e}")
66
+
67
+
68
+ def show_budget_builder():
69
+
70
+ # timer for elapsed_ms
71
+ if "bb_start_ts" not in st.session_state:
72
+ st.session_state.bb_start_ts = time.time()
73
+
74
+
75
+ # Add custom CSS for improved styling
76
+ st.markdown("""
77
+ <style>
78
+ /* Main container styling */
79
+ .main .block-container {
80
+ padding-top: 2rem;
81
+ padding-bottom: 2rem;
82
+ max-width: 1200px;
83
+ }
84
+
85
+ /* Card-like styling for sections */
86
+ .budget-card {
87
+ background: white;
88
+ border-radius: 12px;
89
+ padding: 1.5rem;
90
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
91
+ margin-bottom: 1.5rem;
92
+ border: 1px solid #e5e7eb;
93
+ }
94
+
95
+ /* Header styling */
96
+ .main h1 {
97
+ color: #1f2937;
98
+ font-weight: 700;
99
+ margin-bottom: 0.5rem;
100
+ }
101
+
102
+ .main h2 {
103
+ color: #374151;
104
+ font-weight: 600;
105
+ margin-bottom: 1rem;
106
+ font-size: 1.5rem;
107
+ }
108
+
109
+ .main h3 {
110
+ color: #4b5563;
111
+ font-weight: 600;
112
+ margin-bottom: 0.75rem;
113
+ font-size: 1.25rem;
114
+ }
115
+
116
+ /* Slider styling improvements */
117
+ .stSlider > div > div > div > div {
118
+ background-color: #f3f4f6;
119
+ border-radius: 8px;
120
+ }
121
+
122
+ /* Updated button styling with specific colors for Check Budget (green) and Reset (gray) */
123
+ .stButton > button {
124
+ border-radius: 8px;
125
+ border: none;
126
+ font-weight: 600;
127
+ padding: 0.5rem 1rem;
128
+ transition: all 0.2s;
129
+ }
130
+
131
+ .stButton > button:hover {
132
+ transform: translateY(-1px);
133
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
134
+ }
135
+
136
+ /* Green button for Check Budget */
137
+ .stButton > button[kind="primary"] {
138
+ background-color: #10b981;
139
+ color: white;
140
+ }
141
+
142
+ .stButton > button[kind="primary"]:hover {
143
+ background-color: #059669;
144
+ }
145
+
146
+ /* Gray button for Reset */
147
+ .stButton > button[kind="secondary"] {
148
+ background-color: white;
149
+ color: black;
150
+ }
151
+
152
+ .stButton > button[kind="secondary"]:hover {
153
+ background-color: #f3f4f6;
154
+ }
155
+
156
+ /* Success/Error message styling */
157
+ .stSuccess {
158
+ border-radius: 8px;
159
+ border-left: 4px solid #10b981;
160
+ }
161
+
162
+ .stError {
163
+ border-radius: 8px;
164
+ border-left: 4px solid #ef4444;
165
+ }
166
+
167
+ /* Metric styling */
168
+ .metric-container {
169
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
170
+ border-radius: 12px;
171
+ padding: 1rem;
172
+ border: 1px solid #e2e8f0;
173
+ text-align: center;
174
+ margin-bottom: 1rem;
175
+ }
176
+
177
+ /* Table styling */
178
+ .stTable {
179
+ border-radius: 8px;
180
+ overflow: hidden;
181
+ border: 1px solid #e5e7eb;
182
+ }
183
+
184
+ /* Progress bar styling */
185
+ .stProgress > div > div > div {
186
+ background-color: #10b981;
187
+ border-radius: 4px;
188
+ }
189
+
190
+ /* Info box styling */
191
+ .stInfo {
192
+ border-radius: 8px;
193
+ border-left: 4px solid #3b82f6;
194
+ background-color: #eff6ff;
195
+ }
196
+ </style>
197
+ """, unsafe_allow_html=True)
198
+
199
+ # -----------------------------
200
+ # Define Levels and Categories
201
+ # -----------------------------
202
+ levels = [
203
+ {
204
+ "id": 1,
205
+ "title": "First Budget",
206
+ "description": "Learn basic budget allocation",
207
+ "scenario": "You're 14 and just started getting a weekly allowance. Your parents want to see you can manage money responsibly before increasing it.",
208
+ "income": 300,
209
+ "objectives": [
210
+ "Save at least 20% of your income",
211
+ "Don't spend more than 30% on entertainment",
212
+ "Allocate money for food and transport"
213
+ ],
214
+ "constraints": {
215
+ "savings": {"min": 60},
216
+ "fun": {"max": 90},
217
+ "food": {"min": 40, "required": True},
218
+ "transport": {"min": 30, "required": True},
219
+ },
220
+ "success": [
221
+ ("Save at least JA$60 (20%)", lambda cats, inc: cats["savings"] >= 60),
222
+ ("Keep entertainment under JA$90 (30%)", lambda cats, inc: cats["fun"] <= 90),
223
+ ("Balance your budget completely", lambda cats, inc: sum(cats.values()) == inc),
224
+ ],
225
+ "xp": 20,
226
+ },
227
+ {
228
+ "id": 2,
229
+ "title": "Emergency Fund",
230
+ "description": "Build an emergency fund while managing expenses",
231
+ "scenario": "Your phone broke last month and you had no savings to fix it. This time, build an emergency fund while still enjoying life.",
232
+ "income": 400,
233
+ "objectives": [
234
+ "Build an emergency fund (JA$100+)",
235
+ "Still save for long-term goals",
236
+ "Cover all essential expenses",
237
+ ],
238
+ "constraints": {
239
+ "savings": {"min": 150}, # Emergency + regular savings
240
+ "food": {"min": 60, "required": True},
241
+ "transport": {"min": 40, "required": True},
242
+ "school": {"min": 20, "required": True},
243
+ },
244
+ "success": [
245
+ ("Save at least JA$150 total", lambda cats, inc: cats["savings"] >= 150),
246
+ (
247
+ "Cover all essential expenses",
248
+ lambda cats, inc: cats["food"] >= 60
249
+ and cats["transport"] >= 40
250
+ and cats["school"] >= 20,
251
+ ),
252
+ ],
253
+ "xp": 30,
254
+ },
255
+ {
256
+ "id": 3,
257
+ "title": "Reduced Income",
258
+ "description": "Manage when money is tight",
259
+ "scenario": "Your allowance got cut because of family finances. You need to make tough choices while still maintaining your savings habit.",
260
+ "income": 250,
261
+ "objectives": [
262
+ "Still save something (minimum JA$25)",
263
+ "Cut non-essential spending",
264
+ "Maintain essential expenses",
265
+ ],
266
+ "constraints": {
267
+ "savings": {"min": 25},
268
+ "fun": {"max": 40},
269
+ "food": {"min": 50, "required": True},
270
+ "transport": {"min": 35, "required": True},
271
+ },
272
+ "success": [
273
+ ("Save at least JA$25 (10%)", lambda cats, inc: cats["savings"] >= 25),
274
+ ("Keep entertainment under JA$40", lambda cats, inc: cats["fun"] <= 40),
275
+ ("Balance your budget", lambda cats, inc: sum(cats.values()) == inc),
276
+ ],
277
+ "xp": 35,
278
+ },
279
+ {
280
+ "id": 4,
281
+ "title": "Debt & Goals",
282
+ "description": "Pay off debt while saving for something special",
283
+ "scenario": "You borrowed JA$100 from your parents for a school trip. Now you need to pay it back (JA$25/week) while saving for a new game console.",
284
+ "income": 450,
285
+ "objectives": [
286
+ "Pay debt installment (JA$25)",
287
+ "Save for console (JA$50+ per week)",
288
+ "Don't compromise on essentials",
289
+ ],
290
+ "constraints": {
291
+ "savings": {"min": 75}, # 50 for console + 25 debt payment
292
+ "food": {"min": 70, "required": True},
293
+ "transport": {"min": 45, "required": True},
294
+ "school": {"min": 30, "required": True},
295
+ },
296
+ "success": [
297
+ ("Allocate JA$75+ for savings & debt", lambda cats, inc: cats["savings"] >= 75),
298
+ (
299
+ "Cover all essentials adequately",
300
+ lambda cats, inc: cats["food"] >= 70
301
+ and cats["transport"] >= 45
302
+ and cats["school"] >= 30,
303
+ ),
304
+ ],
305
+ "xp": 40,
306
+ },
307
+ {
308
+ "id": 5,
309
+ "title": "Master Budgeter",
310
+ "description": "Handle multiple financial goals like an adult",
311
+ "scenario": "You're 16 now with part-time job income. Manage multiple goals: emergency fund, college savings, social life, and family contribution.",
312
+ "income": 600,
313
+ "objectives": [
314
+ "Build emergency fund (JA$50)",
315
+ "Save for college (JA$100)",
316
+ "Contribute to family (JA$40)",
317
+ "Maintain social life and hobbies",
318
+ ],
319
+ "constraints": {
320
+ "savings": {"min": 150}, # Emergency + college
321
+ "charity": {"min": 40}, # Family contribution
322
+ "food": {"min": 80, "required": True},
323
+ "transport": {"min": 60, "required": True},
324
+ "school": {"min": 50, "required": True},
325
+ },
326
+ "success": [
327
+ ("Save JA$150+ for future goals", lambda cats, inc: cats["savings"] >= 150),
328
+ ("Contribute JA$40+ to family", lambda cats, inc: cats["charity"] >= 40),
329
+ (
330
+ "Balance entertainment & responsibilities",
331
+ lambda cats, inc: cats["fun"] >= 30 and cats["fun"] <= 150,
332
+ ),
333
+ ("Perfect budget balance", lambda cats, inc: sum(cats.values()) == inc),
334
+ ],
335
+ "xp": 50,
336
+ },
337
+ ]
338
+
339
+ # -----------------------------
340
+ # Initialize Session State
341
+ # -----------------------------
342
+ if "current_level" not in st.session_state:
343
+ st.session_state.current_level = 1
344
+ if "completed_levels" not in st.session_state:
345
+ st.session_state.completed_levels = []
346
+ if "categories" not in st.session_state:
347
+ st.session_state.categories = {}
348
+ if "level_completed" not in st.session_state:
349
+ st.session_state.level_completed = False
350
+
351
+ # -----------------------------
352
+ # Categories Master
353
+ # -----------------------------
354
+ categories_master = {
355
+ "food": {"name": "Food & Snacks", "color": "#16a34a", "icon": "🍎", "min": 0, "max": 300},
356
+ "savings": {"name": "Savings", "color": "#2563eb", "icon": "💰", "min": 0, "max": 400},
357
+ "fun": {"name": "Entertainment", "color": "#dc2626", "icon": "🎮", "min": 0, "max": 300},
358
+ "charity": {"name": "Charity/Family", "color": "#e11d48", "icon": "❤️", "min": 0, "max": 200},
359
+ "transport": {"name": "Transport", "color": "#ea580c", "icon": "🚌", "min": 0, "max": 200},
360
+ "school": {"name": "School Supplies", "color": "#0891b2", "icon": "📚", "min": 0, "max": 150},
361
+ }
362
+ if not st.session_state.categories:
363
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
364
+
365
+ # -----------------------------
366
+ # Current Level Setup
367
+ # -----------------------------
368
+ level = [l for l in levels if l["id"] == st.session_state.current_level][0]
369
+
370
+ # Header section with improved styling
371
+ st.markdown(f"""
372
+ <div style="text-align: center; padding: 2rem 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
373
+ border-radius: 12px; margin-bottom: 2rem; color: white;">
374
+ <h1 style="color: white; margin-bottom: 0.5rem;">💵 Budget Builder</h1>
375
+ </div>
376
+ """, unsafe_allow_html=True)
377
+
378
+ # Level progress indicator
379
+ st.markdown(f"""
380
+ <div style="background: #f8fafc; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; border: 1px solid #e2e8f0;">
381
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
382
+ <span style="font-weight: 600; color: #374151;">Level Progress</span>
383
+ <span style="color: #6b7280;">{len(st.session_state.completed_levels)}/5 Complete</span>
384
+ </div>
385
+ </div>
386
+ """, unsafe_allow_html=True)
387
+
388
+ # Scenario description with better styling
389
+ st.markdown(f"""
390
+ <div style="background: #eff6ff; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem;
391
+ border-left: 4px solid #3b82f6;">
392
+ <h4 style="color: #1e40af; margin-bottom: 0.5rem;">📖 Scenario</h4>
393
+ <p style="color: #1f2937; margin-bottom: 1rem;">{level["scenario"]}</p>
394
+ <div style="background: white; border-radius: 6px; padding: 1rem; border: 1px solid #dbeafe;">
395
+ <strong style="color: #059669;">Weekly Income: JA${level['income']}</strong>
396
+ </div>
397
+ </div>
398
+ """, unsafe_allow_html=True)
399
+
400
+ # -----------------------------
401
+ # Two-column layout
402
+ # -----------------------------
403
+ left_col, right_col = st.columns([2, 1], gap="large")
404
+
405
+ with left_col:
406
+ st.markdown(f"""
407
+ <div class="budget-card" style="background: #f8fafc; border-left: 4px solid #10b981;">
408
+ <h3 style="color: #059669; margin-bottom: 1rem;">🎯 Objectives</h3>
409
+ {''.join([f'<div style="margin-bottom: 0.5rem; color: #374151;">• {obj}</div>' for obj in level["objectives"]])}
410
+ </div>
411
+ """, unsafe_allow_html=True)
412
+
413
+ st.markdown("""
414
+ <h2 style="color: #374151; margin-bottom: 1.5rem;">💰 Budget Allocation</h3>
415
+ <p style="color: #6b7280; margin-bottom: 1.5rem;">Use the sliders below to allocate your weekly income across different categories. Make sure to meet the objectives!</p>
416
+ """, unsafe_allow_html=True)
417
+
418
+ st.markdown("### 📊 Allocate Your Budget")
419
+ # Render sliders without dynamic inter-dependencies
420
+ for cid, cat in categories_master.items():
421
+ constraints = level["constraints"].get(cid, {})
422
+ min_val = 0
423
+ #max is set to the level income for more flexibility
424
+ max_val = level["income"]
425
+ st.session_state.categories[cid] = st.slider(
426
+ f"{cat['icon']} {cat['name']}",
427
+ min_value=min_val,
428
+ max_value=max_val,
429
+ value=st.session_state.categories[cid],
430
+ step=5,
431
+ help=f"Min: JA${min_val}, Max: JA${max_val}"
432
+ )
433
+
434
+ # Calculate totals after sliders have been selected
435
+ total_allocated = sum(st.session_state.categories.values())
436
+ remaining = level["income"] - total_allocated
437
+ st.metric("Remaining", f"JA${remaining}", delta_color="inverse" if remaining < 0 else "normal")
438
+
439
+
440
+ # Remaining budget display with better styling
441
+ color = "#ef4444" if remaining < 0 else "#059669" if remaining == 0 else "#f59e0b"
442
+ st.markdown(f"""
443
+ <div class="metric-container" style="border-left: 4px solid {color};">
444
+ <h4 style="color: #6b7280; margin-bottom: 0.5rem;">Remaining Budget</h4>
445
+ <h2 style="color: {color}; margin: 0;">JA${remaining}</h2>
446
+ </div>
447
+ """, unsafe_allow_html=True)
448
+
449
+ st.markdown('</div>', unsafe_allow_html=True)
450
+
451
+ col1, col2 = st.columns(2)
452
+ with col1:
453
+ if st.button("✅ Check Budget", use_container_width=True, type="primary"):
454
+ results = [(desc, fn(st.session_state.categories, level["income"])) for desc, fn in level["success"]]
455
+ all_passed = all(r[1] for r in results)
456
+
457
+ if all_passed and remaining == 0:
458
+ st.success(f"🎉 Level {level['id']} Complete! +{level['xp']} XP")
459
+ st.session_state.level_completed = True
460
+ if level["id"] not in st.session_state.completed_levels:
461
+ st.session_state.completed_levels.append(level["id"])
462
+
463
+ # award exactly once per level
464
+ award_key = f"_bb_xp_awarded_L{level['id']}"
465
+ if not st.session_state.get(award_key):
466
+ _persist_budget_result(level, success=True, gained_xp=int(level["xp"]))
467
+ st.session_state[award_key] = True
468
+ else:
469
+ st.error("❌ Not complete yet. Check the requirements!")
470
+ for desc, passed in results:
471
+ icon = "✅" if passed else "⚠️"
472
+ st.markdown(f"{icon} {desc}")
473
+
474
+ with col2:
475
+ # Reset button
476
+ if st.button("🔄 Reset Budget", use_container_width=True, type="secondary"):
477
+ # Reset all category amounts
478
+ for cid in categories_master.keys():
479
+ st.session_state[cid] = 0
480
+ # Reset the dictionary in session_state too
481
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
482
+ st.session_state.level_completed = False
483
+ st.rerun()
484
+
485
+
486
+ # Next Level button
487
+ if st.session_state.level_completed and st.session_state.current_level < len(levels):
488
+ if st.button("➡️ Next Level", use_container_width=True, type="primary"):
489
+ st.session_state.current_level += 1
490
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
491
+ st.session_state.level_completed = False
492
+ st.session_state.bb_start_ts = time.time() # <-- reset timer
493
+ st.rerun()
494
+
495
+
496
+ with right_col:
497
+ criteria_html = ""
498
+ for desc, fn in level["success"]:
499
+ passed = fn(st.session_state.categories, level["income"])
500
+ icon = "✅" if passed else "⚠️"
501
+ color = "#059669" if passed else "#f59e0b"
502
+ criteria_html += f"<div style='margin-bottom: 0.5rem; color: {color};'>{icon} {desc}</div>"
503
+
504
+ st.markdown(f"""
505
+ <div class="budget-card">
506
+ <h3 style="color: #374151; margin-bottom: 1rem;">✅ Success Criteria</h3>
507
+ {criteria_html}
508
+ </div>
509
+ """, unsafe_allow_html=True)
510
+
511
+ breakdown_html = ""
512
+ for cid, amount in st.session_state.categories.items():
513
+ if amount > 0:
514
+ cat = categories_master[cid]
515
+ percentage = (amount / level["income"]) * 100
516
+ breakdown_html += f"""
517
+ <div style="display:flex; justify-content:space-between; align-items:center;
518
+ padding:0.5rem; margin-bottom:0.5rem; background:#f8fafc; border-radius:6px;">
519
+ <span style="color:#374151;">{cat['icon']} {cat['name']}</span>
520
+ <div style="text-align:right;">
521
+ <div style="font-weight:600; color:#1f2937;">JA${amount}</div>
522
+ <div style="font-size:0.8rem; color:#6b7280;">{percentage:.1f}%</div>
523
+ </div>
524
+ </div>
525
+ """
526
+
527
+ st.markdown(f"""
528
+ <div class="budget-card">
529
+ <h3 style="color:#374151; margin-bottom:1rem;">📊 Budget Breakdown</h3>
530
+ {breakdown_html}
531
+ </div>
532
+ """, unsafe_allow_html=True)
533
+
534
+
535
+ st.markdown("""
536
+ <div class="budget-card" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
537
+ border-left: 4px solid #f59e0b;">
538
+ <h3 style="color: #92400e; margin-bottom: 1rem;">💡 Level Tips</h3>
539
+ <div style="color: #451a03;">
540
+ <div style="margin-bottom: 0.5rem;">💰 Start with essentials like food and transport</div>
541
+ <div style="margin-bottom: 0.5rem;">🎯 The 50/30/20 rule: needs, wants, savings</div>
542
+ <div>📊 Review and adjust your budget regularly</div>
543
+ </div>
544
+ </div>
545
+ """, unsafe_allow_html=True)
546
+
547
+ if len(st.session_state.completed_levels) == len(levels):
548
+ st.balloons()
549
+ st.markdown("""
550
+ <div style="text-align: center; padding: 2rem; background: linear-gradient(135deg, #10b981 0%, #059669 100%);
551
+ border-radius: 12px; color: white; margin-top: 2rem;">
552
+ <h2 style="color: white; margin-bottom: 1rem;">🎉 Congratulations!</h2>
553
+ <h3 style="color: #d1fae5; margin: 0;">You are now a Master Budgeter!</h3>
554
+ </div>
555
+ <br>
556
+ """, unsafe_allow_html=True)
557
+
558
+ # Show a restart button
559
+ if st.button("🔄 Restart Game"):
560
+ st.session_state.current_level = 1
561
+ st.session_state.completed_levels = []
562
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
563
+ st.session_state.level_completed = False
564
+ st.session_state.bb_start_ts = time.time() # <-- reset timer
565
+ st.rerun()
phase/Student_view/games/debtdilemma.py ADDED
@@ -0,0 +1,1062 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase\Student_view\games\debtdilemma.py
2
+ import streamlit as st
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional, Dict, Literal
5
+ import random
6
+ import math
7
+ import os, time
8
+ from utils import api as backend
9
+ from utils import db as dbapi
10
+
11
+ DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
12
+
13
+ def load_css(file_name: str):
14
+ try:
15
+ with open(file_name, "r", encoding="utf-8") as f:
16
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
17
+ except FileNotFoundError:
18
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
19
+
20
+
21
+ def _refresh_global_xp():
22
+ user = st.session_state.get("user")
23
+ if not user:
24
+ return
25
+ try:
26
+ stats = backend.user_stats(user["user_id"]) if DISABLE_DB else dbapi.user_xp_and_level(user["user_id"])
27
+ st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
28
+ st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
29
+ except Exception as e:
30
+ st.warning(f"XP refresh failed: {e}")
31
+
32
+
33
+ DD_SCOPE_CLASS = "dd-scope"
34
+
35
+ def _ensure_dd_css():
36
+ """Inject CSS for Debt Dilemma buttons once, scoped under .dd-scope."""
37
+
38
+ if st.session_state.get("_dd_css_injected"):
39
+ return
40
+ st.session_state["_dd_css_injected"] = True
41
+
42
+
43
+ st.markdown("""
44
+ <style>
45
+ .dd-scope .stButton > button {
46
+ border: none;
47
+ border-radius: 25px;
48
+ padding: 0.75rem 1.5rem;
49
+ font-weight: 700;
50
+ box-shadow: 0 4px 15px rgba(0,0,0,.2);
51
+ transition: all .3s ease;
52
+ }
53
+ .dd-scope .stButton > button:hover {
54
+ transform: translateY(-2px);
55
+ box-shadow: 0 6px 20px rgba(0,0,0,.3);
56
+ }
57
+ .dd-scope .dd-success .stButton > button {
58
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
59
+ color: #fff;
60
+ }
61
+ .dd-scope .dd-warning .stButton > button {
62
+ background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
63
+ color: #000;
64
+ }
65
+ .dd-scope .dd-danger .stButton > button {
66
+ background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%);
67
+ color: #000;
68
+ }
69
+ .dd-scope .dd-neutral .stButton > button {
70
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
71
+ color: #fff;
72
+ }
73
+ </style>
74
+ """, unsafe_allow_html=True)
75
+
76
+ def buttondd(label: str, *, key: str, variant: str = "neutral", **kwargs) -> bool:
77
+ """
78
+ Scoped button wrapper. Use just like st.button but styles are limited to the Debt Dilemma container.
79
+
80
+ Example:
81
+ buttondd("Pay", key="btn_pay", variant="success", on_click=fn, use_container_width=True)
82
+ """
83
+ _ensure_dd_css()
84
+
85
+ st.markdown(f'<div class="dd-{variant}">', unsafe_allow_html=True)
86
+ clicked = st.button(label, key=key, **kwargs)
87
+ st.markdown('</div>', unsafe_allow_html=True)
88
+ return clicked
89
+
90
+
91
+ setattr(st, "buttondd", buttondd)
92
+
93
+
94
+ # ==== Currency & economy tuning ====
95
+ CURRENCY = "JMD$"
96
+ MONEY_SCALE = 1000 # 1 "game dollar" = 1,000 JMD
97
+
98
+ def jmd(x: int | float) -> int:
99
+ """Scale a base unit to JMD integer."""
100
+ return int(round(x * MONEY_SCALE))
101
+
102
+ def fmt_money(x: int | float) -> str:
103
+ """Format with thousands separator and currency."""
104
+ # round instead of floor so UI doesn't show 0 while a tiny positive remains
105
+ return f"{CURRENCY}{int(round(x)):,}"
106
+
107
+ def clamp_money(x: float) -> int:
108
+ """Round to nearest JMD and never go negative."""
109
+ # helper to normalize all balances to integer JMD
110
+ return max(0, int(round(x)))
111
+
112
+ # Fees (scaled)
113
+ LATE_FEE_BASE = jmd(10) # ~JMD$10,000
114
+ LATE_FEE_PER_MISS = jmd(5) # +JMD$5,000 per missed
115
+ EMERGENCY_FEE = jmd(25) # ~JMD$25,000
116
+ SMALL_PROC_FEE = jmd(2) # ~JMD$2,000 for event shortfalls
117
+
118
+ # ==== Starting wallet config ====
119
+ START_WALLET_MIN = 0
120
+ START_WALLET_MAX = jmd(10) # JMD $0–10,000
121
+ DISBURSE_LOAN_TO_WALLET = False # keep loan off-wallet by default (e.g., pays tuition)
122
+
123
+ # --- Credit-score tuning ---
124
+ CS_EVENT_DECLINE_MIN = 15 # min points to deduct when you skip an expense event
125
+ CS_EVENT_DECLINE_MAX = 100 # max points
126
+ CS_EVENT_DECLINE_PER_K = 5 # ~5 pts per JMD$1,000 of expense you duck
127
+ CS_EMERGENCY_EVENT_HIT = 60 # when an event forces an emergency loan
128
+
129
+ # --- Utilities month-end penalties ---
130
+ UTILITY_NONPAY_CS_HIT = 25
131
+ UTILITY_NONPAY_HAPPY_HIT = 8
132
+ UTILITY_RECONNECT_FEE = jmd(2) # ~JMD$2,000 added to debt
133
+
134
+ # ===============================
135
+ # Types
136
+ # ===============================
137
+ @dataclass
138
+ class LoanDetails:
139
+ principal: int
140
+ interestRate: float
141
+ monthlyPayment: int
142
+ totalOwed: float
143
+ monthsPaid: int
144
+ totalMonths: int
145
+ missedPayments: int
146
+ creditScore: int
147
+
148
+ @dataclass
149
+ class RandomEvent:
150
+ id: str
151
+ title: str
152
+ description: str
153
+ icon: str
154
+ type: Literal['opportunity','expense','penalty','bonus']
155
+ impact: Dict[str, int] = field(default_factory=dict)
156
+ choices: Optional[Dict[str,str]] = None
157
+
158
+ @dataclass
159
+ class GameLevel:
160
+ level: int
161
+ name: str
162
+ loanAmount: int
163
+ interestRate: float
164
+ monthlyPayment: int
165
+ totalMonths: int
166
+ startingIncome: int
167
+ description: str
168
+
169
+ # ===============================
170
+ # Data (shorter game: 3 levels)
171
+ # ===============================
172
+ GAME_LEVELS: List[GameLevel] = [
173
+ GameLevel(1, "🎓 Student Loan", jmd(100), 0.15, jmd(25), 3, jmd(120), "Your first small loan as a student - let's learn together! 📚"),
174
+ GameLevel(2, "🚗 Car Loan", jmd(250), 0.18, jmd(50), 3, jmd(140), "Buying your first car - bigger responsibility but you've got this! 🌟"),
175
+ GameLevel(3, "💳 Credit Card Debt", jmd(400), 0.22, jmd(70), 3, jmd(160), "High-interest credit card debt - time to be extra careful! ⚠️"),
176
+ ]
177
+
178
+ # Replaced 'Clothes' with 'Snacks' and added happiness boosts
179
+ EXPENSES = [
180
+ {"id": "food", "name": "Food", "amount": jmd(0.9), "required": True, "healthImpact": -15, "emoji": "🍎"}, # ~JMD$900 per day
181
+ {"id": "transport", "name": "Transport", "amount": jmd(0.5), "required": True, "healthImpact": 0, "emoji": "🚌"}, # ~JMD$500
182
+ {"id": "utilities", "name": "Utilities", "amount": jmd(7), "required": True, "healthImpact": -5, "emoji": "💡"}, # ~JMD$7,000/mo
183
+ {"id": "entertainment","name": "Entertainment","amount": jmd(1.5), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🎮"},
184
+ {"id": "snacks", "name": "Snacks", "amount": jmd(0.8), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🍿"},
185
+ ]
186
+
187
+ LEVEL_EVENT_POOL = {
188
+ 1: [ # Student Loan level
189
+ RandomEvent("games_day", "🏟️ School Games Day", "Your school is holding Games Day. Small fee, but huge fun and morale!", "🏟️", "expense",
190
+ {"wallet": -jmd(2), "happiness": 5}, {"accept": f"Join ({fmt_money(jmd(2))}, +5% happy)", "decline": "Skip"}),
191
+ RandomEvent("book_fair", "📚 Book Fair", "Discounted textbooks help your grades (and future pay!).", "📚", "opportunity",
192
+ {"wallet": -jmd(3), "income": jmd(1), "happiness": 3}, {"accept": "Buy books", "decline": "Pass"}),
193
+ RandomEvent("tuition_deadline", "🎓 Tuition Deadline", "A small admin fee pops up unexpectedly.", "🎓", "expense",
194
+ {"wallet": -jmd(3.5)}, {"accept": "Pay fee", "decline": "Appeal"}),
195
+ ],
196
+ 2: [ # Car Loan level
197
+ RandomEvent("gas_hike", "⛽ Gas Price Hike", "Fuel costs rise this week.", "⛽", "expense",
198
+ {"wallet": -jmd(2.5)}, {"accept": "Buy gas", "decline": "Drive less"}),
199
+ RandomEvent("oil_change", "🛠️ Discount Oil Change", "Maintenance now saves larger repair later.", "🛠️", "opportunity",
200
+ {"wallet": -jmd(3), "creditScore": 5}),
201
+ ],
202
+ 3: [ # Credit Card level
203
+ RandomEvent("flash_sale", "🛍️ Flash Sale Temptation", "Limited-time sale! Tempting but watch your debt.", "🛍️", "penalty",
204
+ {"debt": jmd(4), "happiness": 4}, {"accept": "Buy (+debt)", "decline": "Resist"}),
205
+ RandomEvent("cashback", "💳 Cashback Bonus", "Your card offers a cashback promo.", "💳", "bonus",
206
+ {"wallet": jmd(3)}),
207
+ ],
208
+ }
209
+
210
+ EVENT_POOL: List[RandomEvent] = [
211
+ # Money-earning opportunities
212
+ RandomEvent("yard_sale", "🧹 Yard Sale Fun!", "You sell old items and make some quick cash! Great job being resourceful! 🌟", "🧹", "opportunity", {"wallet": jmd(6)}),
213
+ RandomEvent("tutoring", "📚 Tutoring Helper", "You help someone with homework and get paid! Sharing knowledge feels great! 😊", "📚", "opportunity", {"wallet": jmd(5)}),
214
+ RandomEvent("odd_jobs", "🧰 Weekend Helper", "You mow lawns and wash a car over the weekend! Hard work pays off! 💪", "🧰", "opportunity", {"wallet": jmd(7)}),
215
+
216
+ # Bonuses / grants
217
+ RandomEvent("overtime_work", "💼 Extra Work Time", "Your boss offers you overtime this period. Extra money but you'll be tired! 😴", "💼", "opportunity",
218
+ {"wallet": jmd(8)}, {"accept": f"Work overtime (+{fmt_money(jmd(8))}) 💪", "decline": "Rest instead 😴"}),
219
+ RandomEvent("freelance_job", "💻 Weekend Project", "A friend asks you to help with their business for some quick cash! 🤝", "💻", "opportunity",
220
+ {"wallet": jmd(6)}, {"accept": f"Take the job (+{fmt_money(jmd(6))}) 💼", "decline": "Enjoy your weekend 🌈"}),
221
+ RandomEvent("bonus_payment", "⭐ Amazing Work!", "Your excellent work this period earned you a bonus! You're doing great! 🎉", "⭐", "bonus", {"wallet": jmd(5), "creditScore": 10}),
222
+ RandomEvent("scholarship_opportunity", "🎓 Learning Reward", "You qualify for a small educational grant! Knowledge pays off! 📖", "🎓", "bonus", {"wallet": jmd(10), "income": jmd(2)}),
223
+
224
+ # Health & happiness helpers
225
+ RandomEvent("mental_health", "🧠 Feeling Better", "A free counseling session can help you feel better and happier! 🌈", "🧠", "opportunity",
226
+ {"wallet": 0, "health": 10, "happiness": 10}, {"accept": "Feel better! 😊", "decline": "Maybe later 🤔"}),
227
+ RandomEvent("health_checkup", "🏥 Health Check", "Local clinic does a free health checkup! Taking care of yourself is important! 💚", "🏥", "opportunity",
228
+ {"wallet": 0, "health": 10, "happiness": 5}, {"accept": "Get healthy! 💪", "decline": "Skip it 🤷"}),
229
+
230
+ # Expenses / penalties
231
+ RandomEvent("landlord_eviction", "🏠 Moving Costs", "You need a small deposit for a new place soon. Moving can be expensive! 📦", "🏠", "expense",
232
+ {"wallet": -jmd(9)}, {"accept": f"Pay deposit (-{fmt_money(jmd(9))}) 🏠", "decline": "Try to negotiate 🤝"}),
233
+ RandomEvent("transport_breakdown", "🚫 Transport Trouble", "Your usual transport is down. You need an alternative way to get around! 🚶", "🚫", "expense",
234
+ {"wallet": -jmd(3)}, {"accept": f"Pay for ride (-{fmt_money(jmd(3))}) 🚗", "decline": "Walk everywhere 🚶"}),
235
+ RandomEvent("utilities_shutoff", "⚡ Utility Warning", "Utilities will be shut off if not paid soon! Don't let the lights go out! 💡", "⚡", "expense",
236
+ {"wallet": -jmd(4)}, {"accept": f"Pay now (-{fmt_money(jmd(4))}) 💡", "decline": "Risk it 😬"}),
237
+ ]
238
+
239
+ # ===============================
240
+ # Helpers
241
+ # ===============================
242
+ def get_level(level:int) -> GameLevel:
243
+ return GAME_LEVELS[level-1]
244
+
245
+ def required_expenses_total() -> int:
246
+ return sum(e["amount"] for e in EXPENSES if e["required"])
247
+
248
+ def progress_percent(total_owed: float, monthly_payment: int, total_months: int) -> float:
249
+ pct = ((total_months - (total_owed / max(monthly_payment,1))) / total_months) * 100
250
+ return max(0.0, min(100.0, pct))
251
+
252
+ def payoff_projection(balance: float, apr: float, monthly_payment: int):
253
+ """
254
+ Simulate payoff using the game's timing:
255
+ - Player pays during the month (before interest).
256
+ - At month end, interest accrues on the remaining balance and is added.
257
+ Returns (months_needed, total_interest_paid). If payment <= interest, returns (None, None).
258
+ """
259
+ r = apr / 12.0
260
+ if balance <= 0:
261
+ return 0, 0
262
+ if r <= 0:
263
+ months = math.ceil(balance / max(1, monthly_payment))
264
+ return months, 0
265
+ if monthly_payment <= balance * r:
266
+ return None, None
267
+ months = 0
268
+ total_interest = 0.0
269
+ b = float(balance)
270
+ for _ in range(10000): # safety cap
271
+ pay = min(monthly_payment, b)
272
+ b -= pay
273
+ months += 1
274
+ if b <= 1e-6:
275
+ break
276
+ interest = b * r
277
+ b += interest
278
+ total_interest += interest
279
+ if monthly_payment <= b * r - 1e-9:
280
+ return None, None
281
+ return months, int(round(total_interest))
282
+
283
+
284
+ def _award_level_completion_if_needed():
285
+ """Give exactly +50 XP once per completed level, including the last level."""
286
+ user = st.session_state.get("user")
287
+ if not user:
288
+ return
289
+ lvl = int(st.session_state.currentLevel)
290
+ key = f"_dd_xp_awarded_L{lvl}"
291
+ if st.session_state.get(key):
292
+ return # already awarded for this level
293
+
294
+ try:
295
+ # compute elapsed time for the level
296
+ start_ts = st.session_state.get("dd_start_ts", time.time())
297
+ elapsed_ms = int(max(0, (time.time() - start_ts) * 1000))
298
+
299
+ if DISABLE_DB:
300
+ # call backend Space
301
+ backend.record_debt_dilemma_play(
302
+ user_id=user["user_id"],
303
+ loans_cleared=1, # you completed the level
304
+ mistakes=int(st.session_state.loan.missedPayments),
305
+ elapsed_ms=elapsed_ms,
306
+ gained_xp=50,
307
+ )
308
+ else:
309
+ # local DB path kept for dev mode
310
+ dbapi.record_debt_dilemma_round(
311
+ user["user_id"],
312
+ level=lvl,
313
+ round_no=0,
314
+ wallet=int(st.session_state.wallet),
315
+ health=int(st.session_state.health),
316
+ happiness=int(st.session_state.happiness),
317
+ credit_score=int(st.session_state.loan.creditScore),
318
+ event_json={"phase": st.session_state.gamePhase},
319
+ outcome=("level_complete" if st.session_state.gamePhase == "level-complete" else "game_complete"),
320
+ gained_xp=50,
321
+ elapsed_ms=elapsed_ms,
322
+ )
323
+
324
+ st.session_state[key] = True
325
+ _refresh_global_xp()
326
+ st.success("Saved +50 XP for completing this loan")
327
+ except Exception as e:
328
+ st.error(f"Could not save completion XP: {e}")
329
+
330
+
331
+ def check_loan_completion() -> bool:
332
+ """Advance level or finish game when loan is cleared. Returns True if game phase changed."""
333
+ loan = st.session_state.loan
334
+ # use integerized check; tiny float dust won't block completion
335
+ if clamp_money(loan.totalOwed) == 0:
336
+ if st.session_state.currentLevel < len(GAME_LEVELS):
337
+ st.session_state.gamePhase = "level-complete"
338
+ st.toast(f"Level {st.session_state.currentLevel} complete! Ready for next?")
339
+ else:
340
+ st.session_state.gamePhase = "completed"
341
+ st.toast("All levels done! 🎉")
342
+ return True
343
+ return False
344
+
345
+ def init_state():
346
+ if "gamePhase" not in st.session_state:
347
+ st.session_state.update({
348
+ "gamePhase": "setup",
349
+ "currentMonth": 1,
350
+ "currentDay": 1,
351
+ "daysInMonth": 28,
352
+ "roundsLeft": 6,
353
+ "wallet": random.randint(START_WALLET_MIN, START_WALLET_MAX),
354
+ "monthlyIncome": GAME_LEVELS[0].startingIncome,
355
+ "health": 100,
356
+ "happiness": 100,
357
+ "monthsWithoutFood": 0,
358
+ "currentEvent": None,
359
+ "eventHistory": [],
360
+ "difficultyMultiplier": 1.0,
361
+ "currentLevel": 1,
362
+ "paidExpenses": [],
363
+ "hasWorkedThisMonth": False,
364
+ "achievements": [],
365
+ "lastWorkPeriod": 0,
366
+ "amountPaidThisMonth": 0,
367
+ "fullPaymentMadeThisMonth": False,
368
+ "paidFoodToday": False,
369
+ })
370
+ lvl = get_level(1)
371
+ st.session_state["loan"] = LoanDetails(
372
+ principal=lvl.loanAmount,
373
+ interestRate=lvl.interestRate,
374
+ monthlyPayment=lvl.monthlyPayment,
375
+ totalOwed=float(lvl.loanAmount),
376
+ monthsPaid=0,
377
+ totalMonths=lvl.totalMonths,
378
+ missedPayments=0,
379
+ creditScore=random.randint(200, 600),
380
+ )
381
+ if "dd_start_ts" not in st.session_state:
382
+ st.session_state.dd_start_ts = time.time()
383
+
384
+ # Fortnight helper (every 2 weeks)
385
+ def current_fortnight() -> int:
386
+ return 1 + (st.session_state.currentDay - 1) // 14
387
+
388
+ # ===== End checks =====
389
+ def check_end_conditions() -> bool:
390
+ if st.session_state.health <= 0:
391
+ st.session_state.health = 0
392
+ st.session_state.gamePhase = "hospital"
393
+ st.toast("You've been hospitalized! Health reached 0%. Game Over.")
394
+ return True
395
+ if st.session_state.happiness <= 0:
396
+ st.session_state.happiness = 0
397
+ st.session_state.gamePhase = "burnout"
398
+ st.toast("Happiness reached 0%. You gave up. Game Over.")
399
+ return True
400
+ return False
401
+
402
+ # ===== Day advancement =====
403
+ def advance_day(no_event: bool = False):
404
+ """Advance one day. If no_event=True, skip daily event roll (use when an action already consumed the day)."""
405
+ if st.session_state.gamePhase == "repaying":
406
+ if not st.session_state.paidFoodToday:
407
+ st.session_state.health = max(0, st.session_state.health - 5)
408
+ st.toast("You skipped food today. Health -5%")
409
+ st.session_state.paidFoodToday = False
410
+
411
+ if not no_event and st.session_state.gamePhase == "repaying" and st.session_state.currentEvent is None:
412
+ new_evt = gen_random_event()
413
+ if new_evt:
414
+ st.session_state.currentEvent = new_evt
415
+ st.toast(f"New event: {new_evt.title}")
416
+ return
417
+
418
+ if check_end_conditions():
419
+ return
420
+
421
+ st.session_state.currentDay += 1
422
+ if st.session_state.currentDay > st.session_state.daysInMonth:
423
+ st.session_state.currentDay = 1
424
+ next_month()
425
+ else:
426
+ st.toast(f"Day {st.session_state.currentDay}/{st.session_state.daysInMonth}")
427
+
428
+ def fast_forward_to_month_end():
429
+ st.toast("Skipping to month end…")
430
+ st.session_state.currentDay = st.session_state.daysInMonth
431
+ next_month()
432
+
433
+ # ===============================
434
+ # Random events
435
+ # ===============================
436
+ def set_event(evt: Optional[RandomEvent]):
437
+ st.session_state["currentEvent"] = evt
438
+
439
+ def gen_random_event() -> Optional[RandomEvent]:
440
+ currentMonth = st.session_state.currentMonth
441
+ difficulty = st.session_state.difficultyMultiplier
442
+ base = 0.08
443
+ eventChance = min(base + (currentMonth * 0.03) + (difficulty * 0.02), 0.4)
444
+ if random.random() < eventChance:
445
+ seen = set(st.session_state.eventHistory)
446
+ level_specific = LEVEL_EVENT_POOL.get(st.session_state.currentLevel, [])
447
+ pool = EVENT_POOL + level_specific
448
+ available = [e for e in pool if (e.id not in seen) or (e.type in ("opportunity","bonus"))]
449
+ if available:
450
+ return random.choice(available)
451
+ return None
452
+
453
+ # ===============================
454
+ # Game actions
455
+ # ===============================
456
+ def start_loan():
457
+ st.session_state.dd_start_ts = time.time() # start elapsed timer
458
+ st.session_state.gamePhase = "repaying"
459
+ if DISBURSE_LOAN_TO_WALLET:
460
+ st.session_state.wallet += st.session_state.loan.principal
461
+ st.toast(f"Loan approved! {fmt_money(st.session_state.loan.principal)} added to your wallet.")
462
+ else:
463
+ st.toast("Loan approved! Funds go directly to fees (not your wallet).")
464
+
465
+ def do_skip_payment():
466
+ loan: LoanDetails = st.session_state.loan
467
+ loan.missedPayments += 1
468
+ loan.creditScore = max(300, loan.creditScore - 50)
469
+ st.toast("Payment missed! Credit score -50.")
470
+ advance_day(no_event=False)
471
+
472
+ def can_work_this_period() -> bool:
473
+ return st.session_state.lastWorkPeriod != current_fortnight()
474
+
475
+ WORK_HAPPINESS_COST = 10
476
+ WORK_MIN = jmd(6) # ~JMD$6,000
477
+ WORK_VAR = jmd(3) # up to +JMD$3,000
478
+
479
+ def do_work_for_money():
480
+ if not can_work_this_period():
481
+ st.toast("You already worked this fortnight. Try later.")
482
+ return
483
+ earnings = WORK_MIN + random.randint(0, WORK_VAR)
484
+ st.session_state.wallet += earnings
485
+ st.session_state.happiness = max(0, st.session_state.happiness - WORK_HAPPINESS_COST)
486
+ st.session_state.hasWorkedThisMonth = True
487
+ st.session_state.lastWorkPeriod = current_fortnight()
488
+ st.toast(f"Work done! +{fmt_money(earnings)}, Happiness -{WORK_HAPPINESS_COST} (uses 1 day)")
489
+ if st.session_state.happiness <= 30 and "workaholic" not in st.session_state.achievements:
490
+ st.session_state.achievements.append("workaholic")
491
+ st.toast("Achievement: Workaholic - Worked while happiness was low!")
492
+ if not check_end_conditions():
493
+ advance_day(no_event=True)
494
+
495
+ def do_make_payment_full():
496
+ make_payment(st.session_state.loan.monthlyPayment)
497
+
498
+ def do_make_payment_partial():
499
+ # use ceil so a tiny remainder (e.g., 0.4 JMD) can be fully cleared
500
+ leftover = math.ceil(st.session_state.loan.totalOwed)
501
+ pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), leftover))
502
+ make_payment(pay_what)
503
+
504
+ def make_payment(amount: int):
505
+ loan: LoanDetails = st.session_state.loan
506
+ required_total = required_expenses_total()
507
+ if amount <= 0:
508
+ st.toast("Enter a valid payment amount.")
509
+ return
510
+ if st.session_state.wallet >= amount and st.session_state.wallet - amount >= required_total:
511
+ st.session_state.wallet -= amount
512
+ # clamp debt after payment to eliminate float dust
513
+ loan.totalOwed = clamp_money(loan.totalOwed - amount)
514
+ st.session_state.amountPaidThisMonth += amount
515
+ if amount >= loan.monthlyPayment:
516
+ loan.monthsPaid += 1
517
+ st.session_state.fullPaymentMadeThisMonth = True
518
+ st.toast(f"Payment successful! {fmt_money(amount)} paid.")
519
+ else:
520
+ still = max(0, loan.monthlyPayment - amount)
521
+ st.toast(f"Partial payment {fmt_money(amount)}. Need {fmt_money(still)} more for full.")
522
+ # rely on integerized zero check
523
+ if check_loan_completion():
524
+ return
525
+ if st.session_state.gamePhase == "repaying":
526
+ advance_day(no_event=True)
527
+ else:
528
+ st.toast("Not enough money (remember mandatory expenses).")
529
+
530
+ # Paying expenses is free; Food heals
531
+ def pay_expense(expense: Dict):
532
+ if st.session_state.wallet >= expense["amount"]:
533
+ st.session_state.wallet -= expense["amount"]
534
+ if expense["id"] not in st.session_state.paidExpenses:
535
+ st.session_state.paidExpenses.append(expense["id"])
536
+ st.toast(f"Paid {fmt_money(expense['amount'])} for {expense['name']}")
537
+ boost = int(expense.get("happinessBoost", 0))
538
+ if boost:
539
+ before = st.session_state.happiness
540
+ st.session_state.happiness = min(100, st.session_state.happiness + boost)
541
+ st.toast(f"Happiness +{st.session_state.happiness - before}%")
542
+ if expense["id"] == "food":
543
+ st.session_state.paidFoodToday = True
544
+ before_h = st.session_state.health
545
+ st.session_state.health = min(100, st.session_state.health + 10)
546
+ healed = st.session_state.health - before_h
547
+ if healed > 0:
548
+ st.toast(f"Health +{healed}% from eating well")
549
+ if expense["id"] == "utilities":
550
+ before = st.session_state.happiness
551
+ st.session_state.happiness = max(0, st.session_state.happiness - 3)
552
+ st.toast(f"Happiness -{before - st.session_state.happiness}% (paid utilities)")
553
+ check_end_conditions()
554
+ else:
555
+ st.toast(f"Can't afford {expense['name']}! It will be auto-deducted at month end.")
556
+
557
+ # Resolving events: consumes 1 day
558
+ def handle_event_choice(accept: bool):
559
+ evt: Optional[RandomEvent] = st.session_state.currentEvent
560
+ if not evt:
561
+ return
562
+ loan: LoanDetails = st.session_state.loan
563
+
564
+ if accept and evt.impact:
565
+ if "wallet" in evt.impact:
566
+ delta = evt.impact["wallet"]
567
+ if delta < 0 and st.session_state.wallet < abs(delta):
568
+ st.toast("You can't afford this! Emergency loan taken.")
569
+ short = abs(delta) + SMALL_PROC_FEE
570
+ st.session_state.wallet = 0
571
+ # clamp after adding emergency shortfall
572
+ loan.totalOwed = clamp_money(loan.totalOwed + short)
573
+ loan.creditScore = max(300,loan.creditScore - CS_EMERGENCY_EVENT_HIT)
574
+ st.toast(f"Added to debt: {fmt_money(short)}")
575
+ else:
576
+ st.session_state.wallet += delta
577
+ st.toast(f"{'+' if delta>0 else ''}{fmt_money(delta)} {'earned' if delta>0 else 'spent'}.")
578
+ if "income" in evt.impact:
579
+ st.session_state.monthlyIncome = max(jmd(0.05), st.session_state.monthlyIncome + evt.impact["income"])
580
+ if "creditScore" in evt.impact:
581
+ loan.creditScore = max(300, min(850, loan.creditScore + evt.impact["creditScore"]))
582
+ if "debt" in evt.impact:
583
+ # clamp after debt increase from event
584
+ loan.totalOwed = clamp_money(loan.totalOwed + evt.impact["debt"])
585
+ if "health" in evt.impact:
586
+ st.session_state.health = min(100, max(0, st.session_state.health + evt.impact["health"]))
587
+ if "happiness" in evt.impact:
588
+ st.session_state.happiness = min(100, max(0, st.session_state.happiness + evt.impact["happiness"]))
589
+ elif not accept:
590
+ if evt.type == "expense":
591
+ st.toast("You avoided the expense but there might be consequences…")
592
+ if random.random() < 0.5 and "wallet" in evt.impact:
593
+ base_k = abs(evt.impact["wallet"]) / MONEY_SCALE # convert JMD → 'thousands'
594
+ penalty = int(round(base_k * CS_EVENT_DECLINE_PER_K))
595
+ penalty = max(CS_EVENT_DECLINE_MIN, min(CS_EVENT_DECLINE_MAX, penalty))
596
+ loan.creditScore = max(300, loan.creditScore - penalty)
597
+ st.toast(f"Credit score penalty: -{penalty}")
598
+ else:
599
+ st.toast("You declined the opportunity.")
600
+
601
+ st.session_state.eventHistory.append(evt.id)
602
+ st.session_state.difficultyMultiplier += 0.1
603
+ st.session_state.currentEvent = None
604
+ if not check_end_conditions():
605
+ advance_day(no_event=True)
606
+
607
+ # ===============================
608
+ # Month processing
609
+ # ===============================
610
+ def check_achievements():
611
+ if st.session_state.health == 100 and "perfect-health" not in st.session_state.achievements:
612
+ st.session_state.achievements.append("perfect-health")
613
+ st.toast("Achievement: Perfect Health!")
614
+ if st.session_state.health <= 20 and "survivor" not in st.session_state.achievements:
615
+ st.session_state.achievements.append("survivor")
616
+ st.toast("Achievement: Survivor!")
617
+ if st.session_state.happiness >= 90 and "happy-camper" not in st.session_state.achievements:
618
+ st.session_state.achievements.append("happy-camper")
619
+ st.toast("Achievement: Happy Camper!")
620
+ if st.session_state.wallet <= jmd(0.01) and st.session_state.happiness >= 50 and "broke-not-broken" not in st.session_state.achievements:
621
+ st.session_state.achievements.append("broke-not-broken")
622
+ st.toast("Achievement: Broke But Not Broken!")
623
+ if st.session_state.loan.creditScore >= 800 and "credit-master" not in st.session_state.achievements:
624
+ st.session_state.achievements.append("credit-master")
625
+ st.toast("Achievement: Credit Master!")
626
+
627
+ def next_month():
628
+ loan: LoanDetails = st.session_state.loan
629
+ st.session_state.lastWorkPeriod = 0
630
+
631
+ if st.session_state.gamePhase == "repaying":
632
+ if not st.session_state.fullPaymentMadeThisMonth:
633
+ loan.missedPayments += 1
634
+ st.toast("You missed this month’s full payment.")
635
+ st.session_state.amountPaidThisMonth = 0
636
+ st.session_state.fullPaymentMadeThisMonth = False
637
+
638
+ unpaid = [e for e in EXPENSES if e["required"] and e["id"] not in st.session_state.paidExpenses]
639
+ total_forced = sum(e["amount"] for e in unpaid)
640
+ total_health_loss = sum(abs(e.get("healthImpact", 0)) for e in unpaid)
641
+
642
+ if total_forced > 0:
643
+ if st.session_state.wallet >= total_forced:
644
+ st.session_state.wallet -= total_forced
645
+ st.session_state.health = max(0, st.session_state.health - total_health_loss)
646
+ st.toast(f"Mandatory expenses auto-deducted: {fmt_money(total_forced)}, Health -{total_health_loss}")
647
+ else:
648
+ shortfall = total_forced - st.session_state.wallet
649
+ st.session_state.wallet = 0
650
+ st.session_state.health = max(0, st.session_state.health - total_health_loss - 10)
651
+ # clamp after emergency shortfall + fee
652
+ loan.totalOwed = clamp_money(loan.totalOwed + shortfall + EMERGENCY_FEE)
653
+ loan.creditScore = max(300, loan.creditScore - 35)
654
+ st.toast(f"Couldn't afford mandatory expenses! Emergency loan: {fmt_money(shortfall + EMERGENCY_FEE)}, Health -{total_health_loss + 10}")
655
+
656
+ if st.session_state.currentLevel >= 3 and st.session_state.wallet < st.session_state.monthlyIncome * 0.5:
657
+ loss = int(((st.session_state.monthlyIncome * 0.5) - st.session_state.wallet) / jmd(1))
658
+ if loss > 0:
659
+ st.session_state.happiness = max(0, st.session_state.happiness - loss)
660
+ st.toast(f"Low funds affecting mood! Happiness -{loss}")
661
+
662
+ st.session_state.currentMonth += 1
663
+ st.session_state.currentDay = 1
664
+ st.session_state.roundsLeft -= 1
665
+ st.session_state.wallet += st.session_state.monthlyIncome
666
+ st.session_state.paidExpenses = []
667
+ st.session_state.hasWorkedThisMonth = False
668
+
669
+ if st.session_state.roundsLeft <= 0:
670
+ st.toast("Time's up! You ran out of rounds!")
671
+ st.session_state.gamePhase = "completed"
672
+ return
673
+
674
+ if loan.missedPayments > 0:
675
+ late_fee = LATE_FEE_BASE + (loan.missedPayments * LATE_FEE_PER_MISS)
676
+ # clamp after applying late fees
677
+ loan.totalOwed = clamp_money(loan.totalOwed + late_fee)
678
+ st.toast(f"Late fee applied: {fmt_money(late_fee)}")
679
+ loan.missedPayments = 0
680
+
681
+ # Extra month-end consequences if Utilities weren't paid
682
+ unpaid_ids = {e["id"] for e in unpaid}
683
+ if "utilities" in unpaid_ids:
684
+ # Credit score & happiness hit + reconnection fee
685
+ loan.creditScore = max(300, loan.creditScore - UTILITY_NONPAY_CS_HIT)
686
+ st.session_state.happiness = max(0, st.session_state.happiness - UTILITY_NONPAY_HAPPY_HIT)
687
+ # clamp after reconnect fee
688
+ loan.totalOwed = clamp_money(loan.totalOwed + UTILITY_RECONNECT_FEE)
689
+ st.toast(
690
+ f"Utilities unpaid: Credit -{UTILITY_NONPAY_CS_HIT}, "
691
+ f"Happiness -{UTILITY_NONPAY_HAPPY_HIT}, "
692
+ f"Reconnect fee {fmt_money(UTILITY_RECONNECT_FEE)}"
693
+ )
694
+
695
+ check_achievements()
696
+
697
+ if st.session_state.gamePhase == "repaying":
698
+ # integerize monthly interest and clamp new total
699
+ monthly_interest = clamp_money((loan.totalOwed * loan.interestRate) / 12.0)
700
+ loan.totalOwed = clamp_money(loan.totalOwed + monthly_interest)
701
+ # ensure completion triggers even after month-end math
702
+ check_loan_completion()
703
+
704
+ st.toast(f"Month {st.session_state.currentMonth}: +{fmt_money(st.session_state.monthlyIncome)} income. {st.session_state.roundsLeft} rounds left.")
705
+ check_end_conditions()
706
+
707
+ # ===============================
708
+ # UI
709
+ # ===============================
710
+ def header():
711
+ level = get_level(st.session_state.currentLevel)
712
+ base_payday_hint = "Paid at month end"
713
+ st.markdown(f"""
714
+ <div class="game-header">
715
+ <div class="game-title">🎮 Debt Dilemma 💳</div>
716
+ <h3>Month {st.session_state.currentMonth} · Day {st.session_state.currentDay}/{st.session_state.daysInMonth}</h3>
717
+ <p>Level {st.session_state.currentLevel}: {level.name}</p>
718
+ </div>
719
+ """, unsafe_allow_html=True)
720
+
721
+ st.markdown(f"""
722
+ <div class="metric-card">
723
+ <h3>📊 Your Status</h3>
724
+ <div style="display:flex;flex-wrap:wrap;gap:1rem;justify-content:space-between;margin-top:1rem;">
725
+ <div><strong>💰 Wallet:</strong> {fmt_money(st.session_state.wallet)}</div>
726
+ <div><strong>💼 Base Salary:</strong> {fmt_money(st.session_state.monthlyIncome)} <small>({base_payday_hint})</small></div>
727
+ <div><strong>📊 Credit:</strong> {st.session_state.loan.creditScore}</div>
728
+ <div><strong>❤️ Health:</strong> {st.session_state.health}%</div>
729
+ <div><strong>😊 Happy:</strong> {st.session_state.happiness}%</div>
730
+ </div>
731
+ <div style="margin-top:.5rem;">
732
+ <small>💡 “Work for Money” is extra: once per fortnight you can earn ~JMD$6k–9k for 1 day, and it lowers happiness by 10%.</small>
733
+ </div>
734
+ </div>
735
+ """, unsafe_allow_html=True)
736
+
737
+ def setup_screen():
738
+ level = get_level(st.session_state.currentLevel)
739
+
740
+ # --- Header ---
741
+ st.markdown(f"""
742
+ <div class="game-header">
743
+ <div class="game-title">🎯 Level {level.level}: {level.name}</div>
744
+ <p>{level.description}</p>
745
+ </div>
746
+ """, unsafe_allow_html=True)
747
+
748
+ # --- Loan Info ---
749
+ months_est, interest_est = payoff_projection(
750
+ balance=float(level.loanAmount),
751
+ apr=level.interestRate,
752
+ monthly_payment=level.monthlyPayment
753
+ )
754
+ if months_est is None:
755
+ proj_html = "<small>🧮 Projection: Payment too low — balance will grow.</small>"
756
+ else:
757
+ years_est = months_est / 12.0
758
+ proj_html = (f"<small>🧮 Projection: ~{months_est} payments (~{years_est:.1f} years), "
759
+ f"est. interest {fmt_money(interest_est)}</small>")
760
+
761
+ st.markdown("### 📋 Loan Details")
762
+ st.markdown(f"""
763
+ <div class="metric-card">
764
+ <h3>💳 Loan Information</h3>
765
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;">
766
+ <div><strong>💰 Amount:</strong> {fmt_money(level.loanAmount)}</div>
767
+ <div><strong>📈 Interest:</strong> {int(level.interestRate*100)}% yearly</div>
768
+ <div><strong>💳 Monthly Payment:</strong> {fmt_money(level.monthlyPayment)}</div>
769
+ <div><strong>⏰ Time Limit (target):</strong> {level.totalMonths} months</div>
770
+ </div>
771
+ <div style="margin-top:.5rem;">{proj_html}</div>
772
+ </div>
773
+ """, unsafe_allow_html=True)
774
+
775
+ # --- Player situation ---
776
+ st.markdown("### 🌟 Your Current Situation")
777
+ st.markdown(f"""
778
+ <div class="metric-card">
779
+ <h3>💼 Your Financial Status</h3>
780
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;">
781
+ <div><strong>💰 Wallet (random start):</strong> {fmt_money(st.session_state.wallet)}</div>
782
+ <div><strong>💼 Base Salary (month end):</strong> {fmt_money(st.session_state.monthlyIncome)}</div>
783
+ <div><strong>🏠 Required Expenses (per month):</strong> {fmt_money(required_expenses_total())}</div>
784
+ <div><strong>📊 Credit Score:</strong> {st.session_state.loan.creditScore}</div>
785
+ </div>
786
+ <div style="margin-top:.5rem;">
787
+ <small>💡 You get your <strong>base salary automatically at month end</strong>. The <em>Work for Money</em> button gives <strong>extra</strong> cash (~JMD$6k–9k) and can be used <strong>once per fortnight</strong> (costs 1 day, -10% happiness).</small>
788
+ </div>
789
+ </div>
790
+ """, unsafe_allow_html=True)
791
+
792
+ st.markdown("""
793
+ ### 🎮 Game Rules - Let's Learn Together!
794
+ 🎯 **Your Mission:** Pay off your loan while staying healthy and happy!
795
+
796
+ 📚 **Important Rules:**
797
+ - 💰 Interest grows your debt each month - pay on time!
798
+ - ❤️ Health 0% = hospital visit (game over!)
799
+ - 😊 Happiness 0% = you give up (game over!)
800
+ - 🍎 Food keeps you healthy (+10 health when paid!)
801
+ - 🎮 Entertainment & 🍿 Snacks make you happy (+5% each!)
802
+ - 🎲 Random events happen daily - some good, some challenging!
803
+
804
+ ⏰ **Time Costs:**
805
+ - 💼 Work (extra) = 1 day (**once per fortnight**)
806
+ - 💳 Make loan payment = 1 day
807
+ - 🎲 Handle events = 1 day
808
+ - 🏠 Paying expenses = FREE (no time cost!)
809
+
810
+ 💡 **Payday:** Your **base salary** hits your wallet automatically at **month end**.
811
+ """)
812
+
813
+ # use st.buttondd (scoped) instead of st.button
814
+ st.buttondd(
815
+ f"🚀 Accept Level {level.level} Loan & {'Receive ' + fmt_money(level.loanAmount) if DISBURSE_LOAN_TO_WALLET else 'Start the Level'}!",
816
+ use_container_width=True,
817
+ on_click=start_loan,
818
+ key="btn_start_loan",
819
+ variant="success"
820
+ )
821
+
822
+ def main_screen():
823
+ header()
824
+
825
+ left, right = st.columns([2,1])
826
+
827
+ with left:
828
+ evt: Optional[RandomEvent] = st.session_state.currentEvent
829
+ if evt:
830
+ st.markdown(f"""
831
+ <div class="event-card">
832
+ <div class="event-title">{evt.icon} {evt.title}</div>
833
+ <p>{evt.description}</p>
834
+ </div>
835
+ """, unsafe_allow_html=True)
836
+
837
+ badge_colors = {
838
+ "opportunity": "🌟 GREAT OPPORTUNITY!",
839
+ "expense": "⚠️ EXPENSE ALERT",
840
+ "penalty": "⛔ CHALLENGE",
841
+ "bonus": "🎁 AWESOME BONUS!"
842
+ }
843
+ st.success(badge_colors[evt.type])
844
+
845
+ c1, c2 = st.columns(2)
846
+ if evt.choices:
847
+ with c1:
848
+ st.buttondd(evt.choices["accept"], use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_accept", variant="success")
849
+ with c2:
850
+ st.buttondd(evt.choices["decline"], use_container_width=True, on_click=lambda: handle_event_choice(False), key="evt_decline", variant="warning")
851
+ else:
852
+ st.buttondd("✨ Continue (uses 1 day)", use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_continue", variant="success")
853
+
854
+ st.markdown("---")
855
+
856
+ # --- Loan status + payoff projection ---
857
+ progress = progress_percent(st.session_state.loan.totalOwed, st.session_state.loan.monthlyPayment, st.session_state.loan.totalMonths)/100
858
+ months_est, interest_est = payoff_projection(
859
+ st.session_state.loan.totalOwed,
860
+ st.session_state.loan.interestRate,
861
+ st.session_state.loan.monthlyPayment
862
+ )
863
+ if months_est is None:
864
+ proj_html = "<div><strong>🧮 Projection:</strong> Payment too low — balance will grow.</div>"
865
+ else:
866
+ years_est = months_est / 12.0
867
+ proj_html = (
868
+ f"<div><strong>🧮 Projection:</strong> ~{months_est} payments "
869
+ f"(~{years_est:.1f} years), est. interest {fmt_money(interest_est)}</div>"
870
+ )
871
+
872
+ st.markdown(f"""
873
+ <div class="metric-card">
874
+ <h3>💳 Loan Status</h3>
875
+ <div style="margin-top: 1rem;">
876
+ <div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
877
+ <div><strong>💰 Still Owed:</strong> {fmt_money(st.session_state.loan.totalOwed)}</div>
878
+ <div><strong>💳 Monthly Due:</strong> {fmt_money(st.session_state.loan.monthlyPayment)}</div>
879
+ </div>
880
+ {proj_html}
881
+ <div style="margin: 1rem 0;">
882
+ <div style="background: #e0e0e0; border-radius: 10px; height: 20px; overflow: hidden;">
883
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); height: 100%; width: {progress*100}%; border-radius: 10px;"></div>
884
+ </div>
885
+ <div style="text-align: center; margin-top: 0.5rem;"><strong>🎯 Progress: {progress*100:.1f}% Complete!</strong></div>
886
+ </div>
887
+ <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem;">
888
+ <div><strong>✅ Payments:</strong> {st.session_state.loan.monthsPaid}/{st.session_state.loan.totalMonths}</div>
889
+ <div><strong>{"⚠️" if st.session_state.loan.missedPayments > 0 else "✅"} Missed:</strong> {st.session_state.loan.missedPayments}</div>
890
+ <div><small>{(st.session_state.loan.interestRate*100/12):.1f}% monthly interest</small></div>
891
+ <div><small>📅 Paid This Month: {fmt_money(st.session_state.amountPaidThisMonth)}{" — ✅ Full" if st.session_state.fullPaymentMadeThisMonth else ""}</small></div>
892
+ </div>
893
+ </div>
894
+ </div>
895
+ """, unsafe_allow_html=True)
896
+
897
+ can_afford = st.session_state.wallet >= (st.session_state.loan.monthlyPayment + required_expenses_total())
898
+ # use ceil so you can actually clear small residuals
899
+ pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), math.ceil(st.session_state.loan.totalOwed)))
900
+
901
+ b1, b2, b3 = st.columns([2,2,2])
902
+ with b1:
903
+ st.buttondd(
904
+ f"💰 Full Payment (1 day) {fmt_money(st.session_state.loan.monthlyPayment)}",
905
+ disabled=not can_afford,
906
+ use_container_width=True,
907
+ on_click=do_make_payment_full,
908
+ key="btn_pay_full",
909
+ variant="success" if can_afford else "warning"
910
+ )
911
+ with b2:
912
+ st.buttondd(
913
+ f"💸 Pay What I Can (1 day) {fmt_money(pay_what)}",
914
+ disabled=pay_what<=0,
915
+ use_container_width=True,
916
+ on_click=do_make_payment_partial,
917
+ key="btn_pay_partial",
918
+ variant="success" if pay_what>0 else "warning"
919
+ )
920
+ with b3:
921
+ st.buttondd("⏭️ Skip Payment (1 day)", use_container_width=True, on_click=do_skip_payment, key="btn_skip", variant="danger")
922
+
923
+ st.markdown("### 🏠 Monthly Expenses (Free Actions - No Time Cost!)")
924
+ cols = st.columns(2)
925
+ for i, exp in enumerate(EXPENSES):
926
+ with cols[i % 2]:
927
+ required_text = "⚠️ Required" if exp["required"] else "🌟 Optional"
928
+ happiness_text = f"<br><small>😊 (+{exp.get('happinessBoost', 0)}% happiness)</small>" if exp.get('happinessBoost', 0) > 0 else ""
929
+ st.markdown(f"""
930
+ <div class="expense-card">
931
+ <h4>{exp['emoji']} {exp['name']} - {fmt_money(exp['amount'])}</h4>
932
+ <p>{required_text}{happiness_text}</p>
933
+ </div>
934
+ """, unsafe_allow_html=True)
935
+ disabled = st.session_state.wallet < exp["amount"]
936
+ st.buttondd(
937
+ f"{exp['emoji']} Pay",
938
+ key=f"pay_{exp['id']}",
939
+ disabled=disabled,
940
+ on_click=lambda e=exp: pay_expense(e),
941
+ use_container_width=True,
942
+ variant="success" if not disabled else "warning"
943
+ )
944
+
945
+ st.markdown("---")
946
+ label = "🌅 End Day & See What Happens!"
947
+ if st.session_state.currentDay == st.session_state.daysInMonth:
948
+ label = f"🗓️ End Month {st.session_state.currentMonth} → Payday: {fmt_money(st.session_state.monthlyIncome)}!"
949
+ st.buttondd(label, disabled=st.session_state.currentEvent is not None, use_container_width=True, on_click=lambda: advance_day(no_event=False), key="btn_end_day", variant="success")
950
+ st.buttondd("⏩ Skip to Month End (When Low on Money!)", use_container_width=True, on_click=fast_forward_to_month_end, key="btn_ff", variant="warning")
951
+
952
+ with right:
953
+ health_color = "🟢" if st.session_state.health > 70 else "🟡" if st.session_state.health > 30 else "🔴"
954
+ happiness_color = "😊" if st.session_state.happiness > 70 else "😐" if st.session_state.happiness > 30 else "😢"
955
+
956
+ net_monthly = st.session_state.monthlyIncome - required_expenses_total() - st.session_state.loan.monthlyPayment
957
+ net_color = "🟢" if net_monthly > 0 else "🔴"
958
+
959
+ st.markdown("### 🌟 Your Wellbeing")
960
+ st.markdown(f"""
961
+ <div class="metric-card">
962
+ <h3>💪 Status Overview</h3>
963
+ <div style="margin-top: 1rem;">
964
+ <div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
965
+ <div><strong>❤️ Health:</strong> {health_color} {st.session_state.health}%</div>
966
+ <div><strong>😊 Happiness:</strong> {happiness_color} {st.session_state.happiness}%</div>
967
+ </div>
968
+ <div style="text-align: center; padding: 1rem 0; border-top: 1px solid #eee;">
969
+ <strong>💹 Monthly Budget:</strong> {net_color} {fmt_money(net_monthly)}<br>
970
+ <small>After loan & required expenses</small>
971
+ </div>
972
+ </div>
973
+ </div>
974
+ """, unsafe_allow_html=True)
975
+
976
+ # Work button
977
+ work_available = can_work_this_period()
978
+ st.buttondd("💼 Work for Money! (1 day, once/fortnight)\n~JMD$6k–9k, -10% Happiness",
979
+ disabled=not work_available,
980
+ on_click=do_work_for_money,
981
+ key="btn_work",
982
+ variant="success" if work_available else "warning")
983
+ cur_fn = current_fortnight()
984
+ st.caption(f"📅 Fortnight {cur_fn}/2 — you can work once each 2 weeks!")
985
+
986
+ def reset_game():
987
+ # INTEGRATION: only reset the Debt Dilemma state; then rerun
988
+ for k in list(st.session_state.keys()):
989
+ # keep global app keys like 'user', 'current_page', 'current_game'
990
+ if k not in {"user", "current_page", "xp", "streak", "current_game", "temp_user"}:
991
+ del st.session_state[k]
992
+ init_state()
993
+ st.session_state.dd_start_ts = time.time() # fresh timer after reset
994
+ st.rerun()
995
+
996
+ def hospital_screen():
997
+ st.error("🏥 You've been hospitalized. Health hit 0%. Game over.")
998
+ # use scoped button
999
+ st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_hospital", variant="success")
1000
+
1001
+ def burnout_screen():
1002
+ st.warning("😵 You burned out. Happiness hit 0%. Game over.")
1003
+ # use scoped button
1004
+ st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_burnout", variant="success")
1005
+
1006
+ def level_complete_screen():
1007
+ _award_level_completion_if_needed()
1008
+ st.success(f"🎉 Level {st.session_state.currentLevel} complete!")
1009
+ def _go_next():
1010
+ st.session_state.currentLevel += 1
1011
+ lvl = get_level(st.session_state.currentLevel)
1012
+ st.session_state.loan = LoanDetails(
1013
+ principal=lvl.loanAmount,
1014
+ interestRate=lvl.interestRate,
1015
+ monthlyPayment=lvl.monthlyPayment,
1016
+ totalOwed=float(lvl.loanAmount),
1017
+ monthsPaid=0,
1018
+ totalMonths=lvl.totalMonths,
1019
+ missedPayments=0,
1020
+ creditScore=st.session_state.loan.creditScore,
1021
+ )
1022
+ st.session_state.monthlyIncome = lvl.startingIncome
1023
+ st.session_state.dd_start_ts = time.time() # reset timer for new level
1024
+ st.session_state.gamePhase = "setup"
1025
+ st.buttondd("➡️ Start next level", on_click=_go_next, key="btn_next_level", variant="success")
1026
+
1027
+ def completed_screen():
1028
+ _award_level_completion_if_needed()
1029
+ st.balloons()
1030
+ st.success("🏁 You’ve finished all levels or ran out of rounds!")
1031
+ # use scoped button
1032
+ st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_completed", variant="success")
1033
+
1034
+ # ===============================
1035
+ # Public entry point expected by game.py
1036
+ # ===============================
1037
+ def show_debt_dilemma():
1038
+
1039
+ load_css(os.path.join("assets", "styles.css"))
1040
+
1041
+ _ensure_dd_css()
1042
+ st.markdown(f'<div class="{DD_SCOPE_CLASS}">', unsafe_allow_html=True) # OPEN SCOPE
1043
+
1044
+ # Initialize game state
1045
+ init_state()
1046
+
1047
+ # Route within the game
1048
+ phase = st.session_state.gamePhase
1049
+ if phase == "setup":
1050
+ setup_screen()
1051
+ elif phase == "hospital":
1052
+ hospital_screen()
1053
+ elif phase == "burnout":
1054
+ burnout_screen()
1055
+ elif phase == "level-complete":
1056
+ level_complete_screen()
1057
+ elif phase == "completed":
1058
+ completed_screen()
1059
+ else:
1060
+ main_screen()
1061
+
1062
+ st.markdown('</div>', unsafe_allow_html=True) # CLOSE SCOPE
phase/Student_view/games/profitpuzzle.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, time
2
+ import streamlit as st
3
+ from utils import api as backend # HTTP to backend Space
4
+ from utils import db as dbapi # direct DB path (only if DISABLE_DB=0)
5
+
6
+ DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
7
+
8
+ def _refresh_global_xp():
9
+ user = st.session_state.get("user")
10
+ if not user:
11
+ return
12
+ try:
13
+ if DISABLE_DB:
14
+ stats = backend.user_stats(user["user_id"])
15
+ else:
16
+ stats = dbapi.user_xp_and_level(user["user_id"])
17
+ st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
18
+ st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
19
+ except Exception as e:
20
+ st.warning(f"XP refresh failed: {e}")
21
+
22
+ # --- CSS Styling ---
23
+ def load_css():
24
+ st.markdown("""
25
+ <style>
26
+ /* Hide Streamlit default elements */
27
+ #MainMenu {visibility: hidden;}
28
+ footer {visibility: hidden;}
29
+ header {visibility: hidden;}
30
+
31
+ /* Main container styling */
32
+ .main .block-container {
33
+ padding-top: 2rem;
34
+ padding-bottom: 2rem;
35
+ font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif;
36
+ }
37
+
38
+ /* Game header styling */
39
+ .game-header {
40
+ background: linear-gradient(135deg, #d946ef, #ec4899);
41
+ padding: 2rem;
42
+ border-radius: 15px;
43
+ color: white;
44
+ text-align: center;
45
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
46
+ margin-bottom: 2rem;
47
+ }
48
+
49
+ /* Scenario card styling */
50
+ .scenario-card {
51
+ background: #ffffff;
52
+ padding: 2rem;
53
+ border-radius: 15px;
54
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
55
+ border: 2px solid #e5e7eb;
56
+ margin-bottom: 1.5rem;
57
+ }
58
+
59
+ /* Variables display */
60
+ .variables-card {
61
+ background: linear-gradient(to right, #4ade80, #22d3ee);
62
+ padding: 1.5rem;
63
+ border-radius: 12px;
64
+ color: white;
65
+ margin: 1rem 0;
66
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
67
+ }
68
+
69
+ /* Progress card */
70
+ .progress-card {
71
+ background: #3b82f6;
72
+ padding: 1.5rem;
73
+ border-radius: 12px;
74
+ color: white;
75
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
76
+ margin-bottom: 1rem;
77
+ }
78
+
79
+ /* XP display */
80
+ .xp-display {
81
+ background: #10b981;
82
+ padding: 1rem;
83
+ border-radius: 12px;
84
+ color: white;
85
+ text-align: center;
86
+ font-weight: bold;
87
+ margin-bottom: 1rem;
88
+ }
89
+
90
+ /* Solution card */
91
+ .solution-card {
92
+ background: #f0f9ff;
93
+ padding: 1.5rem;
94
+ border-radius: 12px;
95
+ border: 2px solid #0ea5e9;
96
+ margin: 1rem 0;
97
+ }
98
+
99
+ /* Custom button styling */
100
+ /* Default button styling (white buttons) */
101
+ .stButton > button {
102
+ background: #ffffff !important;
103
+ color: #111827 !important; /* dark gray text */
104
+ border: 2px solid #d1d5db !important;
105
+ border-radius: 12px !important;
106
+ padding: 0.75rem 1.5rem !important;
107
+ font-weight: bold !important;
108
+ font-size: 1.1rem !important;
109
+ transition: all 0.3s ease !important;
110
+ font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif !important;
111
+ }
112
+
113
+ .stButton > button:hover {
114
+ background: #f9fafb !important;
115
+ transform: translateY(-2px) !important;
116
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
117
+ }
118
+
119
+ /* Next button styling */
120
+ .next-btn button {
121
+ background: #3b82f6 !important;
122
+ color: white !important;
123
+ border: none !important;
124
+ }
125
+ .next-btn button:hover {
126
+ background: #2563eb !important;
127
+ }
128
+
129
+ /* Restart button styling */
130
+ .restart-btn button {
131
+ background: #ec4899 !important;
132
+ color: white !important;
133
+ border: none !important;
134
+ }
135
+ .restart-btn button:hover {
136
+ background: #db2777 !important;
137
+ }
138
+
139
+
140
+
141
+ /* Text input styling */
142
+ .stTextInput > div > div > input {
143
+ border-radius: 12px !important;
144
+ border: 2px solid #d1d5db !important;
145
+ padding: 12px 16px !important;
146
+ font-size: 1.1rem !important;
147
+ font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif !important;
148
+ }
149
+
150
+ .stTextInput > div > div > input:focus {
151
+ border-color: #10b981 !important;
152
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2) !important;
153
+ }
154
+
155
+ /* Slider styling */
156
+ .stSlider > div > div > div {
157
+ background: linear-gradient(to right, #4ade80, #22d3ee) !important;
158
+ }
159
+
160
+ /* Sidebar styling */
161
+ .css-1d391kg {
162
+ background: #f8fafc;
163
+ border-radius: 12px;
164
+ padding: 1rem;
165
+ }
166
+
167
+ /* Success/Error message styling */
168
+ .stSuccess {
169
+ background: #dcfce7 !important;
170
+ border: 2px solid #16a34a !important;
171
+ border-radius: 12px !important;
172
+ color: #15803d !important;
173
+ }
174
+
175
+ .stError {
176
+ background: #fef2f2 !important;
177
+ border: 2px solid #dc2626 !important;
178
+ border-radius: 12px !important;
179
+ color: #dc2626 !important;
180
+ }
181
+
182
+ .stInfo {
183
+ background: #eff6ff !important;
184
+ border: 2px solid #2563eb !important;
185
+ border-radius: 12px !important;
186
+ color: #1d4ed8 !important;
187
+ }
188
+
189
+ .stWarning {
190
+ background: #fffbeb !important;
191
+ border: 2px solid #d97706 !important;
192
+ border-radius: 12px !important;
193
+ color: #92400e !important;
194
+ }
195
+
196
+ /* Difficulty badge styling */
197
+ .difficulty-easy {
198
+ background: #dcfce7;
199
+ color: #16a34a;
200
+ padding: 0.25rem 0.75rem;
201
+ border-radius: 12px;
202
+ font-weight: bold;
203
+ font-size: 0.9rem;
204
+ }
205
+
206
+ .difficulty-medium {
207
+ background: #fef3c7;
208
+ color: #d97706;
209
+ padding: 0.25rem 0.75rem;
210
+ border-radius: 12px;
211
+ font-weight: bold;
212
+ font-size: 0.9rem;
213
+ }
214
+
215
+ .difficulty-hard {
216
+ background: #fecaca;
217
+ color: #dc2626;
218
+ padding: 0.25rem 0.75rem;
219
+ border-radius: 12px;
220
+ font-weight: bold;
221
+ font-size: 0.9rem;
222
+ }
223
+ </style>
224
+ """, unsafe_allow_html=True)
225
+
226
+ #--- Show progress in sidebar ---
227
+ # --- Sidebar Progress ---
228
+ def show_profit_progress_sidebar():
229
+ scenarios = st.session_state.get("profit_scenarios", [])
230
+ total_scenarios = len(scenarios)
231
+ current_s = st.session_state.get("current_scenario", 0)
232
+ completed_count = len(st.session_state.get("completed_scenarios", []))
233
+
234
+ with st.sidebar:
235
+ #add sidebar details for eg
236
+ st.sidebar.markdown(f"""
237
+ <div class="xp-display">
238
+ <h2>🏆 Your Progress</h2>
239
+ <p style="font-size: 1.5rem;">Total XP: {st.session_state.get("score", 0)}</p>
240
+ </div>
241
+ """, unsafe_allow_html=True)
242
+
243
+ st.sidebar.markdown("### 🎯 Challenge List")
244
+ for i, s in enumerate(scenarios):
245
+ if i in st.session_state.completed_scenarios:
246
+ st.sidebar.success(f"✅ {s['title']}")
247
+ elif i == st.session_state.current_scenario:
248
+ st.sidebar.info(f"🎯 {s['title']} (Current)")
249
+ else:
250
+ st.sidebar.write(f"⭕ {s['title']}")
251
+
252
+ st.sidebar.markdown("""
253
+ <div style="background: #f0f9ff; padding: 1rem; border-radius: 12px; border: 2px solid #0ea5e9; margin-top: 1rem;">
254
+ <h3>🧮 Profit Formula</h3>
255
+ <p><strong>Profit = Revenue - Cost</strong></p>
256
+ <hr style="border-color: #0ea5e9;">
257
+ <p><strong>Revenue</strong> = Units × Selling Price</p>
258
+ <p><strong>Cost</strong> = Units × Cost per Unit</p>
259
+ </div>
260
+ """, unsafe_allow_html=True)
261
+
262
+ #space and back button
263
+ st.sidebar.markdown("<br>", unsafe_allow_html=True)
264
+
265
+ if st.button("← Back to Games Hub", use_container_width=True):
266
+ st.session_state.current_game = None
267
+ st.rerun()
268
+
269
+ def _current_scenario():
270
+ ps = st.session_state.get("profit_scenarios", [])
271
+ idx = st.session_state.get("current_scenario", 0)
272
+ return (ps[idx] if ps and 0 <= idx < len(ps) else None)
273
+
274
+
275
+ def next_scenario():
276
+ total = len(st.session_state.get("profit_scenarios", []))
277
+ if st.session_state.get("current_scenario", 0) < total - 1:
278
+ st.session_state.current_scenario += 1
279
+ st.session_state.user_answer = ""
280
+ st.session_state.show_solution = False
281
+ st.rerun()
282
+
283
+ def reset_game():
284
+ st.session_state.current_scenario = 0
285
+ st.session_state.user_answer = ""
286
+ st.session_state.show_solution = False
287
+ st.session_state.score = 0
288
+ st.session_state.completed_scenarios = []
289
+ st.session_state.pp_start_ts = time.time()
290
+ st.rerun()
291
+
292
+
293
+
294
+ # --- Profit Puzzle Game ---
295
+ def show_profit_puzzle():
296
+ # Load CSS styling
297
+ load_css()
298
+
299
+ if "pp_start_ts" not in st.session_state:
300
+ st.session_state.pp_start_ts = time.time()
301
+
302
+ st.markdown("""
303
+ <div class="game-header">
304
+ <h1>🎯 Profit Puzzle Challenge!</h1>
305
+ <p>Learn to calculate profits while having fun! 🚀</p>
306
+ </div>
307
+ """, unsafe_allow_html=True)
308
+
309
+ # -------------------------
310
+ # Game State Management
311
+ # -------------------------
312
+ if "current_scenario" not in st.session_state:
313
+ st.session_state.current_scenario = 0
314
+ if "user_answer" not in st.session_state:
315
+ st.session_state.user_answer = ""
316
+ if "show_solution" not in st.session_state:
317
+ st.session_state.show_solution = False
318
+ if "score" not in st.session_state:
319
+ st.session_state.score = 0
320
+ if "completed_scenarios" not in st.session_state:
321
+ st.session_state.completed_scenarios = []
322
+ if "slider_units" not in st.session_state:
323
+ st.session_state.slider_units = 10
324
+ if "slider_price" not in st.session_state:
325
+ st.session_state.slider_price = 50
326
+ if "slider_cost" not in st.session_state:
327
+ st.session_state.slider_cost = 30
328
+
329
+ # -------------------------
330
+ # Scenario Setup
331
+ # -------------------------
332
+ scenarios = [
333
+ {
334
+ "id": "juice-stand",
335
+ "title": "🧃 Juice Stand Profit",
336
+ "description": "You sold juice at your school event. Calculate your profit!",
337
+ "variables": {"units": 10, "sellingPrice": 50, "costPerUnit": 30},
338
+ "difficulty": "easy",
339
+ "xpReward": 20
340
+ },
341
+ {
342
+ "id": "craft-business",
343
+ "title": "🎨 Craft Business",
344
+ "description": "Your handmade crafts are selling well. What's your profit?",
345
+ "variables": {"units": 15, "sellingPrice": 80, "costPerUnit": 45},
346
+ "difficulty": "medium",
347
+ "xpReward": 20
348
+ },
349
+ {
350
+ "id": "bake-sale",
351
+ "title": "🧁 School Bake Sale",
352
+ "description": "You organized a bake sale fundraiser. Calculate the profit!",
353
+ "variables": {"units": 25, "sellingPrice": 60, "costPerUnit": 35},
354
+ "difficulty": "medium",
355
+ "xpReward": 20
356
+ },
357
+ {
358
+ "id": "tutoring-service",
359
+ "title": "📚 Tutoring Service",
360
+ "description": "You've been tutoring younger students. What's your profit after expenses?",
361
+ "variables": {"units": 8, "sellingPrice": 200, "costPerUnit": 50},
362
+ "difficulty": "hard",
363
+ "xpReward": 40
364
+ },
365
+ {
366
+ "id": "dynamic-scenario",
367
+ "title": "🎮 Custom Business Scenario",
368
+ "description": "Use the sliders to create your own business scenario and calculate profit!",
369
+ "variables": {"units": st.session_state.slider_units,
370
+ "sellingPrice": st.session_state.slider_price,
371
+ "costPerUnit": st.session_state.slider_cost},
372
+ "difficulty": "medium",
373
+ "xpReward": 50
374
+ }
375
+ ]
376
+
377
+ # after scenarios = [...]
378
+ st.session_state.profit_scenarios = scenarios # Store scenarios in session state for sidebar access
379
+ scenario = scenarios[st.session_state.current_scenario]
380
+ is_dynamic = scenario["id"] == "dynamic-scenario"
381
+
382
+ # -------------------------
383
+ # Helper Functions
384
+ # -------------------------
385
+ def calculate_profit(units, price, cost):
386
+ return units * (price - cost)
387
+
388
+ def check_answer():
389
+ try:
390
+ user_val = float(st.session_state.user_answer)
391
+ except ValueError:
392
+ st.warning("Please enter a number.")
393
+ return
394
+
395
+ units = int(scenario["variables"]["units"])
396
+ price = int(scenario["variables"]["sellingPrice"])
397
+ cost = int(scenario["variables"]["costPerUnit"])
398
+ actual_profit = units * (price - cost)
399
+
400
+ correct = abs(user_val - actual_profit) < 0.01
401
+ reward = int(scenario.get("xpReward", 20)) if correct else 0
402
+
403
+ # UI feedback
404
+ if correct:
405
+ st.success(f"✅ Awesome! You got it right! +{reward} XP 🎉")
406
+ st.session_state.score += reward
407
+ if st.session_state.current_scenario not in st.session_state.completed_scenarios:
408
+ st.session_state.completed_scenarios.append(st.session_state.current_scenario)
409
+ else:
410
+ st.error(f"❌ Oops. Correct profit is JA${actual_profit:.2f}")
411
+
412
+ # Persist to TiDB if logged in
413
+ user = st.session_state.get("user")
414
+ if user:
415
+ elapsed_ms = int((time.time() - st.session_state.get("pp_start_ts", time.time())) * 1000)
416
+
417
+ try:
418
+ if DISABLE_DB:
419
+ # Route to backend Space
420
+ backend.record_profit_puzzler_play(
421
+ user_id=user["user_id"],
422
+ puzzles_solved=1 if correct else 0,
423
+ mistakes=0 if correct else 1,
424
+ elapsed_ms=elapsed_ms,
425
+ gained_xp=reward # keep UI and server in sync
426
+ )
427
+ else:
428
+ # Direct DB path if you keep it
429
+ if hasattr(dbapi, "record_profit_puzzler_play"):
430
+ dbapi.record_profit_puzzler_play(
431
+ user_id=user["user_id"],
432
+ puzzles_solved=1 if correct else 0,
433
+ mistakes=0 if correct else 1,
434
+ elapsed_ms=elapsed_ms,
435
+ gained_xp=reward
436
+ )
437
+ else:
438
+ # Fallback to your existing detailed writer
439
+ dbapi.record_profit_puzzle_result(
440
+ user_id=user["user_id"],
441
+ scenario_id=scenario.get("id") or f"scenario_{st.session_state.current_scenario}",
442
+ title=scenario.get("title", f"Scenario {st.session_state.current_scenario+1}"),
443
+ units=int(scenario["variables"]["units"]),
444
+ price=int(scenario["variables"]["sellingPrice"]),
445
+ cost=int(scenario["variables"]["costPerUnit"]),
446
+ user_answer=float(st.session_state.user_answer),
447
+ actual_profit=float(actual_profit),
448
+ is_correct=bool(correct),
449
+ gained_xp=int(reward)
450
+ )
451
+
452
+ _refresh_global_xp()
453
+ except Exception as e:
454
+ st.warning(f"Save failed: {e}")
455
+ else:
456
+ st.info("Login to earn and save XP.")
457
+
458
+ st.session_state.show_solution = True
459
+
460
+ def next_scenario():
461
+ if st.session_state.get("current_scenario", 0) < len(st.session_state.get("profit_scenarios", [])) - 1:
462
+ st.session_state.current_scenario += 1
463
+ st.session_state.user_answer = ""
464
+ st.session_state.show_solution = False
465
+ st.session_state.pp_start_ts = time.time()
466
+ st.rerun()
467
+
468
+ def reset_game():
469
+ st.session_state.current_scenario = 0
470
+ st.session_state.user_answer = ""
471
+ st.session_state.show_solution = False
472
+ st.session_state.score = 0
473
+ st.session_state.completed_scenarios = []
474
+
475
+ # -------------------------
476
+ # UI Layout
477
+ # -------------------------
478
+
479
+ difficulty_class = f"difficulty-{scenario['difficulty']}"
480
+ st.markdown(f"""
481
+ <div class="scenario-card">
482
+ <h2>{scenario['title']}</h2>
483
+ <span class="{difficulty_class}">{scenario['difficulty'].upper()}</span>
484
+ <p style="margin-top: 1rem; font-size: 1.1rem;">{scenario["description"]}</p>
485
+ <h3>📊 Business Details</h3>
486
+ <p><strong>Units Sold:</strong> {scenario['variables']['units']}</p>
487
+ <p><strong>Selling Price per Unit:</strong> JA${scenario['variables']['sellingPrice']}</p>
488
+ <p><strong>Cost per Unit:</strong> JA${scenario['variables']['costPerUnit']}</p>
489
+ </div>
490
+ """, unsafe_allow_html=True)
491
+
492
+ if is_dynamic:
493
+ st.markdown("### 🎛️ Customize Your Business")
494
+ st.session_state.slider_units = st.slider("Units Sold", 1, 50, st.session_state.slider_units)
495
+ st.session_state.slider_price = st.slider("Selling Price per Unit (JA$)", 10, 200, st.session_state.slider_price, 5)
496
+ st.session_state.slider_cost = st.slider("Cost per Unit (JA$)", 5, st.session_state.slider_price - 1, st.session_state.slider_cost, 5)
497
+
498
+ scenario["variables"] = {
499
+ "units": st.session_state.slider_units,
500
+ "sellingPrice": st.session_state.slider_price,
501
+ "costPerUnit": st.session_state.slider_cost
502
+ }
503
+
504
+
505
+ st.markdown("### 💰 What's the profit?")
506
+ st.text_input("Enter Profit (JA$):", key="user_answer", disabled=st.session_state.show_solution, placeholder="Type your answer here...")
507
+
508
+ if not st.session_state.show_solution:
509
+ st.button("🎯 Check My Answer!", on_click=check_answer)
510
+ else:
511
+ actual_profit = calculate_profit(
512
+ scenario["variables"]["units"],
513
+ scenario["variables"]["sellingPrice"],
514
+ scenario["variables"]["costPerUnit"]
515
+ )
516
+
517
+ st.markdown(f"""
518
+ <div class="solution-card">
519
+ <h3>🧮 Solution Breakdown</h3>
520
+ <p><strong>Revenue:</strong> {scenario['variables']['units']} × JA${scenario['variables']['sellingPrice']} = JA${scenario['variables']['units'] * scenario['variables']['sellingPrice']}</p>
521
+ <p><strong>Total Cost:</strong> {scenario['variables']['units']} × JA${scenario['variables']['costPerUnit']} = JA${scenario['variables']['units'] * scenario['variables']['costPerUnit']}</p>
522
+ <p><strong>Profit:</strong> JA${scenario['variables']['units'] * scenario['variables']['sellingPrice']} - JA${scenario['variables']['units'] * scenario['variables']['costPerUnit']} = <span style="color: #10b981; font-weight: bold; font-size: 1.2rem;">JA${actual_profit}</span></p>
523
+ </div>
524
+ """, unsafe_allow_html=True)
525
+
526
+ next_col, restart_col = st.columns(2)
527
+ with next_col:
528
+ if st.session_state.current_scenario < len(scenarios) - 1:
529
+ st.markdown('<div class="next-btn">', unsafe_allow_html=True)
530
+ st.button("➡️ Next Challenge", on_click=next_scenario)
531
+ st.markdown('</div>', unsafe_allow_html=True)
532
+ with restart_col:
533
+ st.markdown('<div class="restart-btn">', unsafe_allow_html=True)
534
+ st.button("🔄 Start Over", on_click=reset_game)
535
+ st.markdown('</div>', unsafe_allow_html=True)
536
+
phase/Student_view/lesson.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from typing import List, Dict, Any, Optional, Tuple
3
+ import re
4
+ import datetime # NEW
5
+
6
+ # Internal API client (already used across the app)
7
+ # Uses BACKEND_URL/BACKEND_TOKEN env vars and has retry logic
8
+ # See utils/api.py for details
9
+ from utils import api as backend_api
10
+
11
+
12
+ FALLBACK_TAG = "<!--fallback-->"
13
+
14
+
15
+ # ---------------------------------------------
16
+ # Page state helpers
17
+ # ---------------------------------------------
18
+ _SS_DEFAULTS = {
19
+ "level": "beginner", # beginner | intermediate | advanced
20
+ "module_id": None, # int (1-based)
21
+ "topic_idx": 0, # 0-based within module
22
+ "mode": "catalog", # catalog | lesson | quiz | results
23
+ "topics_cache": {}, # {(level, module_id): [(title, text), ...]}
24
+ "quiz_data": None, # original quiz payload (list[dict])
25
+ "quiz_answers": {}, # q_index -> "A"|"B"|"C"|"D"
26
+ "quiz_result": None, # backend result dict
27
+ "chatbot_feedback": None, # str
28
+ }
29
+
30
+
31
+ def _ensure_state():
32
+ for k, v in _SS_DEFAULTS.items():
33
+ if k not in st.session_state:
34
+ st.session_state[k] = v
35
+
36
+
37
+ # ---------------------------------------------
38
+ # Content metadata (UI only)
39
+ # ---------------------------------------------
40
+ # These titles mirror the React version you shared, so the experience feels the same.
41
+ MODULES_META: Dict[str, List[Dict[str, Any]]] = {
42
+ "beginner": [
43
+ {
44
+ "id": 1,
45
+ "title": "Understanding Money",
46
+ "description": "Learn the basics of what money is, its uses, and how to manage it.",
47
+ "duration": "20 min",
48
+ "completed": False,
49
+ "locked": False,
50
+ "difficulty": "Easy",
51
+ "topics": [
52
+ "What is Money?",
53
+ "Needs vs. Wants",
54
+ "Earning Money",
55
+ "Saving Money",
56
+ "Spending Wisely",
57
+ "Play: Money Match",
58
+ "Quiz",
59
+ "Summary: My Money Plan"
60
+ ]
61
+ },
62
+ {
63
+ "id": 2,
64
+ "title": "Basic Budgeting",
65
+ "description": "Start building the habit of planning and managing money through budgeting.",
66
+ "duration": "20 min",
67
+ "completed": False,
68
+ "locked": False,
69
+ "difficulty": "Easy",
70
+ "topics": [
71
+ "What is a Budget?",
72
+ "Income and Expenses",
73
+ "Profit and Loss",
74
+ "Saving Goals",
75
+ "Making Choices",
76
+ "Play: Budget Builder",
77
+ "Quiz",
78
+ "Summary: My First Budget"
79
+ ]
80
+ },
81
+ {
82
+ "id": 3,
83
+ "title": "Money in Action",
84
+ "description": "Learn how money is used in everyday transactions and its role in society.",
85
+ "duration": "20 min",
86
+ "completed": False,
87
+ "locked": False,
88
+ "difficulty": "Easy",
89
+ "topics": [
90
+ "Paying for Things",
91
+ "Keeping Track of Money",
92
+ "What Are Taxes?",
93
+ "Giving and Sharing",
94
+ "Money Safety",
95
+ "Play: Piggy Bank Challenge",
96
+ "Quiz",
97
+ "Summary: Money Journal"
98
+ ]
99
+ },
100
+ {
101
+ "id": 4,
102
+ "title": "Simple Business Ideas",
103
+ "description": "Explore the basics of starting a small business and earning profit.",
104
+ "duration": "20 min",
105
+ "completed": False,
106
+ "locked": False,
107
+ "difficulty": "Easy",
108
+ "topics": [
109
+ "What is a Business?",
110
+ "Costs in a Business",
111
+ "Revenue in a Business",
112
+ "Profit in a Business",
113
+ "Advertising Basics",
114
+ "Play: Smart Shopper",
115
+ "Quiz",
116
+ "Summary: My Business Plan"
117
+ ]
118
+ }
119
+ ],
120
+ "intermediate": [],
121
+ "advanced": [],
122
+ }
123
+
124
+
125
+ # Helper to read topic titles regardless of whether metadata uses `topics` or `topic_labels`
126
+
127
+ def _topic_plan(level: str, module_id: int):
128
+ """
129
+ Returns a list of (title, backend_ordinal) after filtering:
130
+ - drop any 'Play:' topic
131
+ - drop 'Quiz'
132
+ - keep first five + the Summary (6 total)
133
+ backend_ordinal is the 1-based index in the original metadata (so backend files line up).
134
+ """
135
+ mod = next(m for m in MODULES_META[level] if m["id"] == module_id)
136
+ raw = (mod.get("topics") or mod.get("topic_labels") or [])
137
+ plan = []
138
+ for i, t in enumerate(raw, start=1):
139
+ tl = t.strip().lower()
140
+ if tl == "quiz" or tl.startswith("play:"):
141
+ continue
142
+ plan.append((t, i))
143
+
144
+ # Ensure at most 6 topics: first five + Summary if present
145
+ if len(plan) > 6:
146
+ # Prefer keeping a 'Summary' entry last if it exists
147
+ summary_pos = next((idx for idx, (title, _) in enumerate(plan)
148
+ if title.strip().lower().startswith("summary")), None)
149
+ if summary_pos is not None:
150
+ plan = plan[:5] + [plan[summary_pos]]
151
+ else:
152
+ plan = plan[:6]
153
+ return plan
154
+
155
+ def _topic_titles(level: str, module_id: int):
156
+ return [t for (t, _) in _topic_plan(level, module_id)]
157
+
158
+
159
+ # ---------------------------------------------
160
+ # Backend integrations
161
+ # ---------------------------------------------
162
+ @st.cache_data(show_spinner=False, ttl=300)
163
+ def _fetch_topic_from_backend(level: str, module_id: int, topic_idx: int) -> Tuple[str, str]:
164
+ """
165
+ Returns (ui_title, content). Tries backend first, never crashes the UI.
166
+ Backend expects folders like: /app/lessons/lesson_{module_id}/topic_{ordinal}.txt
167
+ """
168
+ plan = _topic_plan(level, module_id)
169
+ ui_title, backend_ordinal = plan[topic_idx] # 1-based in original list
170
+
171
+ payload = {
172
+ "lesson": f"lesson_{module_id}", # folder name
173
+ "module": str(module_id), # kept for compatibility
174
+ "topic": str(backend_ordinal), # 1-based ordinal mapping
175
+ }
176
+
177
+ data = {}
178
+ try:
179
+ # Try your documented route, then a simpler alias
180
+ data = backend_api._try_candidates(
181
+ "POST",
182
+ [
183
+ ("/agents/lesson", {"json": payload}),
184
+ ("/lesson", {"json": payload}),
185
+ ],
186
+ ) or {}
187
+ except Exception as e:
188
+ # Log once, keep the UI moving
189
+ st.warning(f"Lesson fetch failed for lesson_{module_id}/topic_{backend_ordinal}: {e}")
190
+ data = {}
191
+
192
+ # Accept several possible shapes
193
+ content = ""
194
+ for k in ("lesson_content", "content", "text", "body"):
195
+ v = data.get(k)
196
+ if isinstance(v, str) and v.strip():
197
+ content = v.strip()
198
+ break
199
+ if isinstance(v, dict):
200
+ vv = v.get("content") or v.get("text") or v.get("body")
201
+ if isinstance(vv, str) and vv.strip():
202
+ content = vv.strip()
203
+ break
204
+
205
+ return ui_title, content
206
+
207
+
208
+ def _extract_takeaways(text: str, max_items: int = 5) -> List[str]:
209
+ """Heuristic key-takeaway extractor from raw lesson text."""
210
+ if not text:
211
+ return []
212
+
213
+ # Prefer explicit sections
214
+ m = re.search(r"(?mi)^\s*(Key\s*Takeaways?|Summary)\s*[:\n]+(.*)$", text, re.DOTALL)
215
+ if m:
216
+ body = m.group(2)
217
+ lines = [ln.strip(" •-*–\t") for ln in body.splitlines() if ln.strip()]
218
+ items = [ln for ln in lines if len(ln) > 3][:max_items]
219
+ if items:
220
+ return items
221
+
222
+ # Otherwise, harvest bullet-y looking lines
223
+ bullets = [
224
+ ln.strip(" •-*–\t")
225
+ for ln in text.splitlines()
226
+ if ln.strip().startswith(("-", "•", "*", "–")) and len(ln.strip()) > 3
227
+ ]
228
+ if bullets:
229
+ return bullets[:max_items]
230
+
231
+ # Fallback: first few sentences
232
+ sents = re.split(r"(?<=[.!?])\s+", text.strip())
233
+ return [s for s in sents if len(s) > 20][:min(max_items, 3)]
234
+
235
+
236
+ def _start_quiz(level: str, module_id: int) -> Optional[List[Dict[str, Any]]]:
237
+ """Ask backend to generate a 5-question mini quiz for this module."""
238
+ module_conf = next(m for m in MODULES_META[level] if m["id"] == module_id)
239
+ try:
240
+ quiz = backend_api.generate_quiz(
241
+ lesson_id=module_id, # int id works; backend uses it for retrieval bucketing
242
+ level_slug=level, # "beginner" | "intermediate" | "advanced"
243
+ lesson_title=module_conf["title"],
244
+ )
245
+ if isinstance(quiz, list) and quiz:
246
+ return quiz
247
+ return None
248
+ except Exception as e:
249
+ st.error(f"Could not generate quiz: {e}")
250
+ return None
251
+
252
+
253
+ def _submit_quiz(level: str, module_id: int, original_quiz: List[Dict[str, Any]], answers_map: Dict[int, str]) -> Optional[Dict[str, Any]]:
254
+ """Submit answers and get score + tutor feedback."""
255
+ user_answers = []
256
+ for i, q in enumerate(original_quiz):
257
+ # Expect letters A-D; default to ""
258
+ user_answers.append({
259
+ "question": q.get("question", f"Q{i+1}"),
260
+ "answer": answers_map.get(i, ""),
261
+ })
262
+ try:
263
+ result = backend_api.submit_quiz(
264
+ lesson_id=module_id,
265
+ level_slug=level,
266
+ user_answers=user_answers,
267
+ original_quiz=original_quiz,
268
+ )
269
+ return result
270
+ except Exception as e:
271
+ st.error(f"Could not submit quiz: {e}")
272
+ return None
273
+
274
+ def _send_quiz_summary_to_chatbot(result: Dict[str, Any]):
275
+ """
276
+ Send a concise, actionable summary of the quiz outcome to the chatbot,
277
+ then navigate to the Chatbot page with the conversation pre-seeded.
278
+ """
279
+ level = st.session_state.level
280
+ module_id = st.session_state.module_id
281
+ mod = next(m for m in MODULES_META[level] if m["id"] == module_id)
282
+
283
+ score = result.get("score", {})
284
+ correct = int(score.get("correct", 0))
285
+ total = int(score.get("total", 0))
286
+ feedback = (result.get("feedback") or st.session_state.get("chatbot_feedback") or "").strip()
287
+
288
+ user_prompt = (
289
+ f"I just finished the quiz for '{mod['title']}' (module {module_id}) "
290
+ f"and scored {correct}/{total}. Please give me 2–3 targeted tips and 1 tiny action "
291
+ f"to improve before the next lesson. If there were wrong answers, explain them simply.\n\n"
292
+ f"Context from grader:\n{feedback}"
293
+ )
294
+
295
+ try:
296
+ # Hit your FastAPI chatbot route (HF under the hood)
297
+ resp = backend_api.send_to_chatbot([
298
+ {"role": "system", "content": "You are a friendly financial tutor for Jamaican students."},
299
+ {"role": "user", "content": user_prompt}
300
+ ])
301
+ bot_reply = (resp or {}).get("reply", "").strip()
302
+ except Exception as e:
303
+ bot_reply = f"(Chatbot unavailable) Based on your result: {feedback or 'Nice work!'}"
304
+
305
+ # Seed the Chatbot page's message list so the conversation is visible immediately
306
+ msgs = st.session_state.get("messages") or [{
307
+ "id": "1",
308
+ "text": "Hi! I'm your AI Financial Tutor. What would you like to learn today?",
309
+ "sender": "assistant",
310
+ "timestamp": datetime.datetime.now(),
311
+ }]
312
+ msgs.append({"text": user_prompt, "sender": "user", "timestamp": datetime.datetime.now()})
313
+ msgs.append({"text": bot_reply, "sender": "assistant", "timestamp": datetime.datetime.now()})
314
+ st.session_state.messages = msgs
315
+
316
+ # Jump straight to Chatbot page
317
+ st.session_state.current_page = "Chatbot"
318
+
319
+ def _fallback_text(title: str, module_id: int, topic_ordinal: int) -> str:
320
+ """
321
+ Minimal on-brand copy if the backend has not supplied a topic file yet.
322
+ Tailored to beginner modules so the UI stays useful.
323
+ """
324
+ t = title.strip().lower()
325
+ if "what is money" in t or "money" == t:
326
+ return ("Money is a tool we use to trade for goods and services. "
327
+ "In Jamaica we use JMD coins and notes, and many people also pay digitally. "
328
+ "You can spend, save, or share money, but it is limited, so plan how you use it.") + "\n" + FALLBACK_TAG
329
+ if "need" in t and "want" in t:
330
+ return ("Needs keep you safe and healthy, like food, clothes, and school supplies. "
331
+ "Wants are nice to have, like toys or snacks. Cover needs first, then plan for wants.") + "\n" + FALLBACK_TAG
332
+ if "earn" in t:
333
+ return ("You earn money by doing work or providing value. Small jobs add up. "
334
+ "Earnings give you choices to spend, save, or share, and teach the value of effort.") + "\n" + FALLBACK_TAG
335
+ if "sav" in t:
336
+ return ("Saving means putting aside some money now for future needs or goals. "
337
+ "Start small and be consistent. A jar, a partner plan, or a bank account all help.") + "\n" + FALLBACK_TAG
338
+ if "spend" in t or "wisely" in t:
339
+ return ("Spending wisely means comparing prices, making a simple budget, and avoiding impulse buys. "
340
+ "Aim for best value so your goals stay on track.") + "\n" + FALLBACK_TAG
341
+ if "summary" in t or "journal" in t or "plan" in t:
342
+ return ("Quick recap: cover needs first, set a small saving goal, and make one spending rule for the week. "
343
+ "Write one action you will try before the next lesson.") + "\n" + FALLBACK_TAG
344
+ if "quiz" in t:
345
+ return ("Take a short quiz to check your understanding of this lesson.") + "\n" + FALLBACK_TAG
346
+ # Generic fallback
347
+ return (f"This unit will be populated from lesson_{module_id}/topic_{topic_ordinal}.txt. "
348
+ "For now, review the key idea and write one example from daily life.") + "\n" + FALLBACK_TAG
349
+
350
+
351
+ # ---------------------------------------------
352
+ # UI building blocks
353
+ # ---------------------------------------------
354
+ def _render_catalog():
355
+ st.header("Financial Education")
356
+ st.caption("Build your financial knowledge with structured paths for every skill level.")
357
+
358
+ level = st.session_state.get("level", _SS_DEFAULTS["level"])
359
+
360
+ cols = st.columns(3)
361
+ for i, mod in enumerate(MODULES_META[level]):
362
+ with cols[i % 3]:
363
+ st.subheader(mod["title"])
364
+ if mod.get("description"):
365
+ st.caption(mod["description"])
366
+ st.caption(f"Duration: {mod.get('duration','—')} · Difficulty: {mod.get('difficulty','—')}")
367
+ with st.expander("Topics include"):
368
+ for t, _ord in _topic_plan(level, mod["id"]):
369
+ st.write("• ", t)
370
+ if st.button("Start Learning", key=f"start_{level}_{mod['id']}"):
371
+ # nuke stale topic cache for this module and any cached fetch failures
372
+ st.session_state.topics_cache.pop((level, mod["id"]), None)
373
+ try:
374
+ st.cache_data.clear()
375
+ except Exception:
376
+ pass
377
+
378
+ st.session_state.module_id = mod["id"]
379
+ st.session_state.topic_idx = 0
380
+ st.session_state.mode = "lesson"
381
+ st.rerun()
382
+
383
+
384
+
385
+ def _render_lesson():
386
+ level = st.session_state.level
387
+ module_id = st.session_state.module_id
388
+ if module_id is None:
389
+ st.session_state.mode = "catalog"
390
+ st.rerun()
391
+
392
+ mod = next(m for m in MODULES_META[level] if m["id"] == module_id)
393
+
394
+ st.markdown(f"### {mod['title']}")
395
+ if mod.get("description"):
396
+ st.caption(mod["description"])
397
+
398
+ topics = _get_topics(level, module_id)
399
+
400
+ if not topics:
401
+ # build topics from metadata and use fallbacks so the page is never blank
402
+ plan = _topic_plan(level, module_id)
403
+ topics = [(title, _fallback_text(title, module_id, i + 1)) for i, (title, _) in enumerate(plan)]
404
+ st.session_state.topics_cache[(level, module_id)] = topics
405
+
406
+ with st.container(border=True):
407
+ progress = (st.session_state.topic_idx + 1) / max(1, len(topics))
408
+ st.progress(progress, text=f"Unit {st.session_state.topic_idx + 1} of {len(topics)}")
409
+
410
+ t_title, t_text = topics[st.session_state.topic_idx]
411
+
412
+ # Special Quiz placeholder
413
+ if t_title.strip().lower() == "quiz":
414
+ with st.spinner("Generating quiz…"):
415
+ quiz = _start_quiz(level, module_id)
416
+ if quiz:
417
+ st.session_state.quiz_data = quiz
418
+ st.session_state.quiz_answers = {}
419
+ st.session_state.mode = "quiz"
420
+ st.rerun()
421
+ else:
422
+ st.error("Quiz could not be generated. Please try again or skip.")
423
+ return
424
+
425
+ st.subheader(t_title)
426
+ source_note = "Default" if FALLBACK_TAG in t_text else "Backend"
427
+ st.caption(f"Source: {source_note}")
428
+ if t_text:
429
+ # strip the marker and render HTML from backend files (e.g., <b>Money</b>)
430
+ cleaned = t_text.replace(FALLBACK_TAG, "")
431
+ st.markdown(cleaned, unsafe_allow_html=True)
432
+
433
+ takeaways = _extract_takeaways(cleaned)
434
+ if takeaways:
435
+ st.markdown("#### Key Takeaways")
436
+ for it in takeaways:
437
+ st.write("✅ ", it)
438
+ else:
439
+ st.info("Content coming soon.")
440
+
441
+ col1, col2, col3 = st.columns([1, 1, 1])
442
+ with col1:
443
+ if st.button("← Previous", disabled=st.session_state.topic_idx == 0):
444
+ st.session_state.topic_idx -= 1
445
+ st.rerun()
446
+ with col2:
447
+ if st.button("Back to Modules"):
448
+ st.session_state.mode = "catalog"
449
+ st.session_state.module_id = None
450
+ st.rerun()
451
+ with col3:
452
+ is_last = st.session_state.topic_idx >= len(topics) - 1
453
+
454
+ if is_last:
455
+ # NEW: auto-start quiz when learner reaches the last topic
456
+ if not st.session_state.get("_auto_quiz_started", False):
457
+ st.session_state["_auto_quiz_started"] = True
458
+ with st.spinner("Generating quiz…"):
459
+ quiz = _start_quiz(level, module_id)
460
+ if quiz:
461
+ st.session_state.quiz_data = quiz
462
+ st.session_state.quiz_answers = {}
463
+ st.session_state.mode = "quiz"
464
+ st.rerun()
465
+ else:
466
+ st.error("Quiz could not be generated. Please try again.")
467
+ else:
468
+ # Fallback button if auto-start once failed
469
+ if st.button("Take Lesson Quiz →"):
470
+ with st.spinner("Generating quiz…"):
471
+ quiz = _start_quiz(level, module_id)
472
+ if quiz:
473
+ st.session_state.quiz_data = quiz
474
+ st.session_state.quiz_answers = {}
475
+ st.session_state.mode = "quiz"
476
+ st.rerun()
477
+ else:
478
+ st.error("Quiz could not be generated. Please try again.")
479
+ else:
480
+ if st.button("Next →"):
481
+ st.session_state.topic_idx += 1
482
+ st.rerun()
483
+
484
+
485
+ with st.expander("Module Units", expanded=False):
486
+ for i, (tt, _) in enumerate(topics):
487
+ label = f"{i+1}. {tt}"
488
+ st.button(label, key=f"jump_{i}", on_click=lambda j=i: st.session_state.update({"topic_idx": j}) or st.rerun())
489
+
490
+ def _get_topics(level: str, module_id: int) -> List[Tuple[str, str]]:
491
+ """
492
+ Build the six-topic plan from metadata titles, try backend for each,
493
+ and if content is missing, provide a short fallback paragraph.
494
+ """
495
+ cache_key = (level, module_id)
496
+ if cache_key in st.session_state.topics_cache:
497
+ return st.session_state.topics_cache[cache_key]
498
+
499
+ plan = _topic_plan(level, module_id) # six titles max
500
+ out: List[Tuple[str, str]] = []
501
+
502
+ for idx in range(len(plan)):
503
+ title, content = _fetch_topic_from_backend(level, module_id, idx)
504
+ if not content:
505
+ content = _fallback_text(title, module_id, idx + 1)
506
+ out.append((title, content))
507
+
508
+ st.session_state.topics_cache[cache_key] = out
509
+ return out
510
+
511
+ def _letter_for(i: int) -> str:
512
+ return chr(ord("A") + i)
513
+
514
+
515
+ def _render_quiz():
516
+ quiz: List[Dict[str, Any]] = st.session_state.quiz_data or []
517
+ if not quiz:
518
+ st.session_state.mode = "lesson"
519
+ st.rerun()
520
+
521
+ st.markdown("### Lesson Quiz")
522
+
523
+ # Render each question as a block (single page quiz)
524
+ for q_idx, q in enumerate(quiz):
525
+ st.markdown(f"**Q{q_idx+1}. {q.get('question','').strip()}**")
526
+ opts = q.get("options") or []
527
+ # Build labels like "A. option"
528
+ labels = [f"{_letter_for(i)}. {opt}" for i, opt in enumerate(opts)]
529
+
530
+ def _on_select():
531
+ sel = st.session_state[f"ans_{q_idx}"] # "A. option text"
532
+ letter = sel.split(".", 1)[0] if isinstance(sel, str) else ""
533
+ st.session_state.quiz_answers[q_idx] = letter # store "A".."D"
534
+
535
+ # Preselect previously chosen letter, if any
536
+ saved_letter = st.session_state.quiz_answers.get(q_idx) # "A"
537
+ pre_idx = next((i for i, l in enumerate(labels) if saved_letter and l.startswith(f"{saved_letter}.")), None)
538
+
539
+ st.radio(
540
+ "",
541
+ labels,
542
+ index=pre_idx, # None means no default
543
+ key=f"ans_{q_idx}",
544
+ on_change=_on_select,
545
+ )
546
+ st.divider()
547
+
548
+ # Submit
549
+ all_answered = len(st.session_state.quiz_answers) == len(quiz)
550
+ if st.button("Submit Quiz", disabled=not all_answered):
551
+ with st.spinner("Grading…"):
552
+ result = _submit_quiz(
553
+ st.session_state.level,
554
+ st.session_state.module_id,
555
+ quiz,
556
+ st.session_state.quiz_answers,
557
+ )
558
+ if result:
559
+ st.session_state.quiz_result = result
560
+ st.session_state.chatbot_feedback = result.get("feedback")
561
+ # NEW: immediately send to chatbot and navigate there
562
+ _send_quiz_summary_to_chatbot(result)
563
+ st.rerun()
564
+
565
+
566
+
567
+ def _render_results():
568
+ result = st.session_state.quiz_result or {}
569
+ score = result.get("score", {})
570
+ correct = score.get("correct", 0)
571
+ total = score.get("total", 0)
572
+
573
+ st.success(f"Quiz Complete! You scored {correct} / {total}.")
574
+
575
+ wrong = result.get("wrong", [])
576
+ if wrong:
577
+ with st.expander("Review your answers"):
578
+ for w in wrong:
579
+ st.markdown(f"**{w.get('question','')}**")
580
+ st.write(f"Your answer: {w.get('your_answer','')}")
581
+ st.write(f"Correct answer: {w.get('correct_answer','')}")
582
+ st.divider()
583
+
584
+ fb = st.session_state.chatbot_feedback
585
+ if fb:
586
+ st.markdown("#### Tutor Explanation")
587
+ st.write(fb)
588
+
589
+ level = st.session_state.level
590
+ module_id = st.session_state.module_id
591
+ planned = next((m.get("topics", []) for m in MODULES_META[level] if m["id"] == module_id), [])
592
+ try:
593
+ quiz_index = [t.strip().lower() for t in planned].index("quiz")
594
+ except ValueError:
595
+ quiz_index = None
596
+
597
+ c1, c2, c3 = st.columns([1, 1, 1])
598
+ with c1:
599
+ if st.button("Back to Modules"):
600
+ st.session_state.mode = "catalog"
601
+ st.session_state.module_id = None
602
+ st.rerun()
603
+ with c2:
604
+ if st.button("Ask the Chatbot →"):
605
+ st.session_state.current_page = "Chatbot"
606
+ st.session_state.chatbot_prefill = fb
607
+ st.rerun()
608
+ with c3:
609
+ if quiz_index is not None and quiz_index + 1 < len(planned):
610
+ if st.button("Continue Lesson →"):
611
+ st.session_state.mode = "lesson"
612
+ st.session_state.topic_idx = quiz_index + 1
613
+ st.rerun()
614
+
615
+
616
+ # ---------------------------------------------
617
+ # Public entry point(s)
618
+ # ---------------------------------------------
619
+
620
+ def render():
621
+ _ensure_state()
622
+
623
+ # Breadcrumb
624
+ st.caption("Learning Path · " + st.session_state.level.capitalize())
625
+
626
+ mode = st.session_state.mode
627
+ if mode == "catalog":
628
+ _render_catalog()
629
+ elif mode == "lesson":
630
+ _render_lesson()
631
+ elif mode == "quiz":
632
+ _render_quiz()
633
+ elif mode == "results":
634
+ _render_results()
635
+ else:
636
+ st.session_state.mode = "catalog"
637
+ _render_catalog()
638
+
639
+
640
+ # Some parts of the app import pages and call a conventional `show()`
641
+ show_page = render
642
+
643
+
644
+ if __name__ == "__main__":
645
+ # Allow standalone run for local testing
646
+ st.set_page_config(page_title="Lesson", page_icon="📘", layout="centered")
647
+ render()
648
+
649
+ #comment
phase/Student_view/practice_quiz.py ADDED
@@ -0,0 +1 @@
 
 
1
+ #added a practice_quiz.py (for the general practice quiz code could go here and the lesson_quiz code stuff in quiz.py)
phase/Student_view/quiz.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import streamlit as st
3
+ from utils.quizdata import quizzes_data
4
+ import datetime
5
+ import json
6
+ from utils import db as dbapi
7
+ import utils.api as api
8
+
9
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
10
+
11
+ def _get_quiz_from_source(quiz_id: int):
12
+ """
13
+ Fetch a quiz payload from the local DB (if enabled) or from the backend API.
14
+ Expected backend shape: {'quiz': {...}, 'items': [...]}
15
+ """
16
+ if USE_LOCAL_DB and hasattr(dbapi, "get_quiz"):
17
+ return dbapi.get_quiz(quiz_id)
18
+ # backend: expose GET /quizzes/{quiz_id}
19
+ return api.get_quiz(quiz_id)
20
+
21
+ def _submit_quiz_result(student_id: int, assignment_id: int, quiz_id: int,
22
+ score: int, total: int, details: dict):
23
+ """
24
+ Submit a quiz result either to the local DB or to the backend API.
25
+ """
26
+ if USE_LOCAL_DB and hasattr(dbapi, "submit_quiz"):
27
+ return dbapi.submit_quiz(student_id=student_id,
28
+ assignment_id=assignment_id,
29
+ quiz_id=quiz_id,
30
+ score=score, total=total, details=details)
31
+ # backend: POST /quizzes/submit (or your route of choice)
32
+ # utils.api should wrap that route; below assumes api.submit_quiz exists.
33
+ return api.submit_quiz(student_id=student_id,
34
+ assignment_id=assignment_id,
35
+ quiz_id=quiz_id,
36
+ score=score, total=total, details=details)
37
+
38
+ def _load_quiz_obj(quiz_id):
39
+ """
40
+ Return a normalized quiz object from either quizzes_data (built-in)
41
+ or the backend/DB. Normalized shape:
42
+ {"title": str, "questions": [{"question","options","answer","points"}...]}
43
+ """
44
+ # Built-ins first
45
+ if quiz_id in quizzes_data:
46
+ q = quizzes_data[quiz_id]
47
+ for qq in q.get("questions", []):
48
+ qq.setdefault("points", 1)
49
+ return q
50
+
51
+ # Teacher-assigned (DB/backend)
52
+ data = _get_quiz_from_source(int(quiz_id)) # <-- uses API when DISABLE_DB=1
53
+ if not data:
54
+ return {"title": f"Quiz {quiz_id}", "questions": []}
55
+
56
+ items_out = []
57
+ for it in (data.get("items") or []):
58
+ opts = it.get("options")
59
+ if isinstance(opts, (str, bytes)):
60
+ try:
61
+ opts = json.loads(opts)
62
+ except Exception:
63
+ opts = []
64
+ opts = opts or []
65
+
66
+ ans = it.get("answer_key")
67
+ if isinstance(ans, (str, bytes)):
68
+ try:
69
+ ans = json.loads(ans) # support '["A","C"]'
70
+ except Exception:
71
+ pass # allow "A"
72
+
73
+ def letter_to_text(letter):
74
+ if isinstance(letter, str):
75
+ idx = ord(letter.upper()) - 65
76
+ return opts[idx] if 0 <= idx < len(opts) else letter
77
+ return letter
78
+
79
+ if isinstance(ans, list):
80
+ ans_text = [letter_to_text(a) for a in ans]
81
+ else:
82
+ ans_text = letter_to_text(ans)
83
+
84
+ items_out.append({
85
+ "question": it.get("question", ""),
86
+ "options": opts,
87
+ "answer": ans_text, # text or list of texts
88
+ "points": int(it.get("points", 1)),
89
+ })
90
+
91
+ title = (data.get("quiz") or {}).get("title", f"Quiz {quiz_id}")
92
+ return {"title": title, "questions": items_out}
93
+
94
+ def _letter_to_index(ch: str) -> int:
95
+ return ord(ch.upper()) - 65 # 'A'->0, 'B'->1, ...
96
+
97
+ def _correct_to_indices(correct, options: list[str]):
98
+ """
99
+ Map 'correct' (letters like 'A' or ['A','C'] OR option text(s)) -> list of indices.
100
+ """
101
+ idxs = []
102
+ if isinstance(correct, list):
103
+ for c in correct:
104
+ if isinstance(c, str):
105
+ if len(c) == 1 and c.isalpha():
106
+ idxs.append(_letter_to_index(c))
107
+ elif c in options:
108
+ idxs.append(options.index(c))
109
+ elif isinstance(correct, str):
110
+ if len(correct) == 1 and correct.isalpha():
111
+ idxs.append(_letter_to_index(correct))
112
+ elif correct in options:
113
+ idxs.append(options.index(correct))
114
+ # keep only valid unique indices
115
+ return sorted({i for i in idxs if 0 <= i < len(options)})
116
+
117
+ def _normalize_user_to_indices(user_answer, options: list[str]):
118
+ """
119
+ user_answer can be option text (or list of texts), or letters; return indices.
120
+ """
121
+ idxs = []
122
+ if isinstance(user_answer, list):
123
+ for a in user_answer:
124
+ if isinstance(a, str):
125
+ if a in options:
126
+ idxs.append(options.index(a))
127
+ elif len(a) == 1 and a.isalpha():
128
+ idxs.append(_letter_to_index(a))
129
+ elif isinstance(user_answer, str):
130
+ if user_answer in options:
131
+ idxs.append(options.index(user_answer))
132
+ elif len(user_answer) == 1 and user_answer.isalpha():
133
+ idxs.append(_letter_to_index(user_answer))
134
+ return sorted([i for i in idxs if 0 <= i < len(options)])
135
+
136
+ # --- Helper for level styling ---
137
+ def get_level_style(level):
138
+ if level.lower() == "beginner":
139
+ return ("#28a745", "Beginner") # Green
140
+ elif level.lower() == "intermediate":
141
+ return ("#ffc107", "Intermediate") # Yellow
142
+ elif level.lower() == "advanced":
143
+ return ("#dc3545", "Advanced") # Red
144
+ else:
145
+ return ("#6c757d", level)
146
+
147
+
148
+ # --- Sidebar Progress ---
149
+ def show_quiz_progress_sidebar(quiz_id):
150
+ qobj = _load_quiz_obj(quiz_id)
151
+ total_q = max(1, len(qobj.get("questions", [])))
152
+ current_q = int(st.session_state.get("current_q", 0))
153
+ answered_count = len(st.session_state.get("answers", {}))
154
+
155
+ with st.sidebar:
156
+ st.markdown("""
157
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
158
+ <h3 style="margin: 0; color: #333;">Quiz Progress</h3>
159
+ <div style="font-size: 18px;">☰</div>
160
+ </div>
161
+ """, unsafe_allow_html=True)
162
+
163
+ st.markdown(f"""
164
+ <div style="margin-bottom: 15px;">
165
+ <strong style="color: #333; font-size: 14px;">{qobj.get('title','Quiz')}</strong>
166
+ </div>
167
+ """, unsafe_allow_html=True)
168
+
169
+ progress_value = (current_q) / total_q if current_q < total_q else 1.0
170
+ st.progress(progress_value)
171
+
172
+ st.markdown(f"""
173
+ <div style="text-align: center; margin: 10px 0; font-weight: bold; color: #333;">
174
+ {min(current_q + 1, total_q)} of {total_q}
175
+ </div>
176
+ """, unsafe_allow_html=True)
177
+
178
+ cols = st.columns(5)
179
+ for i in range(total_q):
180
+ col = cols[i % 5]
181
+ with col:
182
+ if i == current_q and current_q < total_q:
183
+ st.markdown(f"""
184
+ <div style="background-color: #28a745; color: white; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; font-weight: bold; font-size: 14px;">
185
+ {i + 1}
186
+ </div>
187
+ """, unsafe_allow_html=True)
188
+ elif i in st.session_state.get("answers", {}):
189
+ st.markdown(f"""
190
+ <div style="background-color: #d4edda; color: #155724; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; font-size: 14px;">
191
+ {i + 1}
192
+ </div>
193
+ """, unsafe_allow_html=True)
194
+ else:
195
+ st.markdown(f"""
196
+ <div style="background-color: #f8f9fa; color: #6c757d; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; border: 1px solid #dee2e6; font-size: 14px;">
197
+ {i + 1}
198
+ </div>
199
+ """, unsafe_allow_html=True)
200
+
201
+ st.markdown(f"""
202
+ <div style="font-size: 12px; color: #666; margin: 15px 0;">
203
+ <div style="margin: 5px 0;">
204
+ <span style="display: inline-block; width: 12px; height: 12px; background-color: #28a745; border-radius: 50%; margin-right: 8px;"></span>
205
+ <span>Answered ({answered_count})</span>
206
+ </div>
207
+ <div style="margin: 5px 0;">
208
+ <span style="display: inline-block; width: 12px; height: 12px; background-color: #17a2b8; border-radius: 50%; margin-right: 8px;"></span>
209
+ <span>Current</span>
210
+ </div>
211
+ <div style="margin: 5px 0;">
212
+ <span style="display: inline-block; width: 12px; height: 12px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 50%; margin-right: 8px;"></span>
213
+ <span>Not answered</span>
214
+ </div>
215
+ </div>
216
+ """, unsafe_allow_html=True)
217
+
218
+ if st.button("← Back to Quizzes", use_container_width=True):
219
+ st.session_state.selected_quiz = None
220
+ st.rerun()
221
+
222
+ # --- Quiz Question ---
223
+ def show_quiz(quiz_id):
224
+ qobj = _load_quiz_obj(quiz_id)
225
+ q_index = int(st.session_state.current_q)
226
+ questions = qobj.get("questions", [])
227
+ question_data = questions[q_index]
228
+
229
+ st.header(qobj.get("title", "Quiz"))
230
+ st.subheader(question_data.get("question", ""))
231
+
232
+ options = question_data.get("options", [])
233
+ correct_answer = question_data.get("answer")
234
+ key = f"q_{q_index}"
235
+ prev_answer = st.session_state.answers.get(q_index)
236
+
237
+ if isinstance(correct_answer, list):
238
+ # multiselect; convert any letter defaults to texts
239
+ default_texts = []
240
+ if isinstance(prev_answer, list):
241
+ for a in prev_answer:
242
+ if isinstance(a, str):
243
+ if a in options:
244
+ default_texts.append(a)
245
+ elif len(a) == 1 and a.isalpha():
246
+ i = _letter_to_index(a)
247
+ if 0 <= i < len(options):
248
+ default_texts.append(options[i])
249
+ answer = st.multiselect("Select all that apply:", options, default=default_texts, key=key)
250
+ else:
251
+ # single answer; compute default index from letter or text
252
+ if isinstance(prev_answer, str):
253
+ if prev_answer in options:
254
+ default_idx = options.index(prev_answer)
255
+ elif len(prev_answer) == 1 and prev_answer.isalpha():
256
+ i = _letter_to_index(prev_answer)
257
+ default_idx = i if 0 <= i < len(options) else 0
258
+ else:
259
+ default_idx = 0
260
+ else:
261
+ default_idx = 0
262
+ answer = st.radio("Select your answer:", options, index=default_idx, key=key)
263
+
264
+ st.session_state.answers[q_index] = answer # auto-save
265
+
266
+ if st.button("Next Question ➡"):
267
+ st.session_state.current_q += 1
268
+ st.rerun()
269
+
270
+
271
+
272
+ # --- Quiz Results ---
273
+ def show_results(quiz_id):
274
+ qobj = _load_quiz_obj(quiz_id)
275
+ questions = qobj.get("questions", [])
276
+
277
+ total_points = 0
278
+ earned_points = 0
279
+ details = {"answers": {}}
280
+
281
+ for i, q in enumerate(questions):
282
+ options = q.get("options", []) or []
283
+ pts = int(q.get("points", 1))
284
+ total_points += pts
285
+
286
+ correct = q.get("answer")
287
+ correct_idx = _correct_to_indices(correct, options)
288
+
289
+ user_answer = st.session_state.answers.get(i)
290
+ user_idx = _normalize_user_to_indices(user_answer, options)
291
+
292
+ is_correct = (sorted(user_idx) == sorted(correct_idx))
293
+ if is_correct:
294
+ earned_points += pts
295
+
296
+ # friendly display
297
+ correct_disp = ", ".join(options[j] for j in correct_idx if 0 <= j < len(options)) or str(correct)
298
+ user_disp = ", ".join(options[j] for j in user_idx if 0 <= j < len(options)) or (
299
+ ", ".join(user_answer) if isinstance(user_answer, list) else str(user_answer)
300
+ )
301
+
302
+ if is_correct:
303
+ st.markdown(f"✅ **Q{i+1}: {q.get('question','')}** \nYour answer: {user_disp}")
304
+ else:
305
+ st.markdown(f"❌ **Q{i+1}: {q.get('question','')}** \nYour answer: {user_disp} \nCorrect answer: {correct_disp}")
306
+
307
+ details["answers"][str(i+1)] = {
308
+ "question": q.get("question", ""),
309
+ "selected": user_answer,
310
+ "correct": correct,
311
+ "points": pts,
312
+ "earned": pts if is_correct else 0
313
+ }
314
+
315
+ percent = int(round(100 * earned_points / max(1, total_points)))
316
+ st.success(f"{qobj.get('title','Quiz')} - Completed! 🎉")
317
+ st.markdown(f"### 🏆 Score: {percent}% ({earned_points}/{total_points} points)")
318
+
319
+ # Save submission to DB for assigned quizzes
320
+ if isinstance(quiz_id, int):
321
+ assignment_id = st.session_state.get("current_assignment")
322
+ if assignment_id:
323
+ _submit_quiz_result(
324
+ student_id=st.session_state.user["user_id"],
325
+ assignment_id=assignment_id,
326
+ quiz_id=quiz_id,
327
+ score=int(earned_points),
328
+ total=int(total_points),
329
+ details=details
330
+ )
331
+
332
+ if st.button("🔁 Retake Quiz"):
333
+ st.session_state.current_q = 0
334
+ st.session_state.answers = {}
335
+ st.rerun()
336
+
337
+ if st.button("⬅ Back to Quizzes"):
338
+ st.session_state.selected_quiz = None
339
+ st.rerun()
340
+
341
+ # tutor handoff (kept as-is)
342
+ wrong_answers = []
343
+ for i, q in enumerate(questions):
344
+ user_answer = st.session_state.answers.get(i)
345
+ correct = q.get("answer")
346
+ if (isinstance(correct, list) and set(user_answer or []) != set(correct)) or (not isinstance(correct, list) and user_answer != correct):
347
+ wrong_answers.append((q.get("question",""), user_answer, correct, q.get("explanation","")))
348
+ if wrong_answers and st.button("💬 Talk to AI Financial Tutor"):
349
+ st.session_state.selected_quiz = None
350
+ st.session_state.current_page = "Chatbot"
351
+ st.session_state.current_q = 0
352
+ st.session_state.answers = {}
353
+ if "messages" not in st.session_state:
354
+ st.session_state.messages = []
355
+ wrong_q_text = "\n".join(
356
+ [f"Q: {q}\nYour answer: {ua}\nCorrect answer: {ca}\nExplanation: {ex}"
357
+ for q, ua, ca, ex in wrong_answers])
358
+ tutor_prompt = f"I just completed a financial quiz and got some questions wrong. Here are the details:\n{wrong_q_text}\nCan you help me understand these concepts better?"
359
+ st.session_state.messages.append({
360
+ "id": str(datetime.datetime.now().timestamp()),
361
+ "text": tutor_prompt,
362
+ "sender": "user",
363
+ "timestamp": datetime.datetime.now()
364
+ })
365
+ st.session_state.is_typing = True
366
+ st.rerun()
367
+
368
+ # --- Quiz List ---
369
+ def show_quiz_list():
370
+ st.title("📊 Financial Knowledge Quizzes")
371
+ st.caption("Test your financial literacy across different modules")
372
+
373
+ cols = st.columns(3)
374
+ for i, (quiz_id, quiz) in enumerate(quizzes_data.items()):
375
+ col = cols[i % 3]
376
+ with col:
377
+ color, label = get_level_style(quiz["level"])
378
+ st.markdown(f"""
379
+ <div style="border:1px solid #e1e5e9; border-radius:12px; padding:20px; margin-bottom:20px; background:white; box-shadow:0 2px 6px rgba(0,0,0,0.08);">
380
+ <span style="background-color:{color}; color:white; font-size:12px; padding:4px 8px; border-radius:6px;">{label}</span>
381
+ <span style="float:right; color:#666; font-size:13px;">⏱ {quiz['duration']}</span>
382
+ <h4 style="margin-top:10px; margin-bottom:6px; color:#222;">{quiz['title']}</h4>
383
+ <p style="font-size:14px; color:#555; line-height:1.4; margin-bottom:10px;">{quiz['description']}</p>
384
+ <p style="font-size:13px; color:#666;">📝 {len(quiz['questions'])} questions</p>
385
+ </div>
386
+ """, unsafe_allow_html=True)
387
+
388
+ if st.button("Start Quiz ➡", key=f"quiz_{quiz_id}"):
389
+ st.session_state.selected_quiz = quiz_id
390
+ st.session_state.current_q = 0
391
+ st.session_state.answers = {}
392
+ st.rerun()
393
+
394
+
395
+ # --- Main Router for Quiz Page ---
396
+ def show_page():
397
+ if "selected_quiz" not in st.session_state:
398
+ st.session_state.selected_quiz = None
399
+ if "current_q" not in st.session_state:
400
+ st.session_state.current_q = 0
401
+ if "answers" not in st.session_state:
402
+ st.session_state.answers = {}
403
+
404
+ if st.session_state.selected_quiz is None:
405
+ show_quiz_list()
406
+ else:
407
+ quiz_id = st.session_state.selected_quiz
408
+ qobj = _load_quiz_obj(quiz_id)
409
+ total_q = len(qobj.get("questions", []))
410
+ if st.session_state.current_q < total_q:
411
+ show_quiz(quiz_id)
412
+ else:
413
+ show_results(quiz_id)
phase/Student_view/teacherlink.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Student_view/teacherlink.py
2
+ import os
3
+ import streamlit as st
4
+ from utils import db as dbapi
5
+ import utils.api as api # <-- backend Space client
6
+
7
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1" # DB only when DISABLE_DB=0
8
+
9
+ def load_css(file_name: str):
10
+ try:
11
+ with open(file_name, "r", encoding="utf-8") as f:
12
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
13
+ except FileNotFoundError:
14
+ pass
15
+
16
+ def _progress_0_1(v):
17
+ try:
18
+ f = float(v)
19
+ except Exception:
20
+ return 0.0
21
+ # accept 0..1 or 0..100
22
+ return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
23
+
24
+ # --- Small wrappers to switch between DB and Backend ---
25
+
26
+ def _join_class_by_code(student_id: int, code: str):
27
+ if USE_LOCAL_DB and hasattr(dbapi, "join_class_by_code"):
28
+ return dbapi.join_class_by_code(student_id, code)
29
+ return api.join_class_by_code(student_id, code)
30
+
31
+ def _list_classes_for_student(student_id: int):
32
+ if USE_LOCAL_DB and hasattr(dbapi, "list_classes_for_student"):
33
+ return dbapi.list_classes_for_student(student_id)
34
+ try:
35
+ return api.list_classes_for_student(student_id)
36
+ except Exception:
37
+ return []
38
+
39
+ def _class_content_counts(class_id: int):
40
+ if USE_LOCAL_DB and hasattr(dbapi, "class_content_counts"):
41
+ return dbapi.class_content_counts(class_id)
42
+ try:
43
+ return api.class_content_counts(class_id)
44
+ except Exception:
45
+ return {"lessons": 0, "quizzes": 0}
46
+
47
+ def _student_class_progress(student_id: int, class_id: int):
48
+ if USE_LOCAL_DB and hasattr(dbapi, "student_class_progress"):
49
+ return dbapi.student_class_progress(student_id, class_id)
50
+ try:
51
+ return api.student_class_progress(student_id, class_id)
52
+ except Exception:
53
+ return {
54
+ "overall_progress": 0,
55
+ "lessons_completed": 0,
56
+ "total_assigned_lessons": 0,
57
+ "avg_score": 0,
58
+ }
59
+
60
+ def _leave_class(student_id: int, class_id: int):
61
+ if USE_LOCAL_DB and hasattr(dbapi, "leave_class"):
62
+ return dbapi.leave_class(student_id, class_id)
63
+ return api.leave_class(student_id, class_id)
64
+
65
+ def _student_assignments_for_class(student_id: int, class_id: int):
66
+ if USE_LOCAL_DB and hasattr(dbapi, "student_assignments_for_class"):
67
+ return dbapi.student_assignments_for_class(student_id, class_id)
68
+ try:
69
+ return api.student_assignments_for_class(student_id, class_id)
70
+ except Exception:
71
+ return []
72
+
73
+ # --- UI ---
74
+
75
+ def show_code():
76
+ load_css(os.path.join("assets", "styles.css"))
77
+
78
+ if "user" not in st.session_state or not st.session_state.user:
79
+ st.error("Please log in as a student.")
80
+ return
81
+ if st.session_state.user["role"] != "Student":
82
+ st.error("This page is for students.")
83
+ return
84
+
85
+ student_id = st.session_state.user["user_id"]
86
+ st.markdown("## 👥 Join a Class")
87
+ st.caption("Enter class code from your teacher")
88
+
89
+ raw = st.text_input(
90
+ label="Class Code",
91
+ placeholder="e.g. FIN5A2024",
92
+ key="class_code_input",
93
+ label_visibility="collapsed"
94
+ )
95
+
96
+ # custom button style
97
+ st.markdown(
98
+ """
99
+ <style>
100
+ .stButton>button#join_class_btn {
101
+ background-color: #28a745; /* Bootstrap green */
102
+ color: white;
103
+ border-radius: 5px;
104
+ padding: 10px 16px;
105
+ font-weight: 600;
106
+ }
107
+ .stButton>button#join_class_btn:hover {
108
+ background-color: #218838;
109
+ color: white;
110
+ }
111
+ </style>
112
+ """,
113
+ unsafe_allow_html=True,
114
+ )
115
+
116
+ if st.button("Join Class", key="join_class_btn"):
117
+ code = (raw or "").strip().upper()
118
+ if not code:
119
+ st.error("Enter a class code.")
120
+ else:
121
+ try:
122
+ _join_class_by_code(student_id, code)
123
+ st.success("🎉 Joined the class!")
124
+ st.rerun()
125
+ except ValueError as e:
126
+ st.error(str(e))
127
+ except Exception as e:
128
+ st.error(f"Could not join class: {e}")
129
+
130
+ st.markdown("---")
131
+ st.markdown("## Your Classes")
132
+
133
+ classes = _list_classes_for_student(student_id)
134
+ if not classes:
135
+ st.info("You haven’t joined any classes yet. Ask your teacher for a class code.")
136
+ return
137
+
138
+ # one card per class
139
+ for c in classes:
140
+ class_id = c["class_id"]
141
+ counts = _class_content_counts(class_id) # lessons/quizzes count
142
+ prog = _student_class_progress(student_id, class_id)
143
+
144
+ st.markdown(f"### {c.get('name', 'Untitled Class')}")
145
+ st.caption(
146
+ f"Teacher: {c.get('teacher_name','—')} • "
147
+ f"Code: {c.get('code','—')} • "
148
+ f"Joined: {str(c.get('joined_at',''))[:10] if c.get('joined_at') else '—'}"
149
+ )
150
+
151
+ st.progress(_progress_0_1(prog.get("overall_progress", 0)))
152
+ avg_pct = int(round(100 * _progress_0_1(prog.get("avg_score", 0))))
153
+ st.caption(
154
+ f"{prog.get('lessons_completed', 0)}/{prog.get('total_assigned_lessons', 0)} lessons completed • "
155
+ f"Avg quiz: {avg_pct}%"
156
+ )
157
+
158
+ # top metrics
159
+ m1, m2, m3, m4 = st.columns(4)
160
+ m1.metric("Lessons", counts.get("lessons", 0))
161
+ m2.metric("Quizzes", counts.get("quizzes", 0))
162
+ m3.metric("Overall", f"{int(round(100 * _progress_0_1(prog.get('overall_progress', 0))))}%")
163
+ m4.metric("Avg Quiz", f"{avg_pct}%")
164
+
165
+ # Leave class
166
+ leave_col, _ = st.columns([1, 3])
167
+ with leave_col:
168
+ if st.button("🚪 Leave Class", key=f"leave_{class_id}"):
169
+ try:
170
+ _leave_class(student_id, class_id)
171
+ st.toast("Left class.", icon="👋")
172
+ st.rerun()
173
+ except Exception as e:
174
+ st.error(f"Could not leave class: {e}")
175
+
176
+ # Assignments for THIS class with THIS student's progress
177
+ st.markdown("#### Teacher Lessons & Quizzes")
178
+ rows = _student_assignments_for_class(student_id, class_id)
179
+ if not rows:
180
+ st.info("No assignments yet.")
181
+ else:
182
+ lessons_tab, quizzes_tab = st.tabs(["📘 Lessons", "🏆 Quizzes"])
183
+
184
+ with lessons_tab:
185
+ for r in rows:
186
+ if r.get("lesson_id") is None:
187
+ continue
188
+
189
+ status = r.get("status") or "not_started"
190
+ pos = r.get("current_pos") or 0
191
+ # if backend returns explicit progress % or 0..1, keep it sane:
192
+ pct = r.get("progress")
193
+ if pct is None:
194
+ # fallback: estimate from position
195
+ pct = 1.0 if status == "completed" else min(0.95, float(pos or 0) * 0.1)
196
+
197
+ st.subheader(r.get("title", "Untitled"))
198
+ due = r.get("due_at")
199
+ due_txt = f"Due: {str(due)[:10]}" if due else "—"
200
+ st.caption(f"{r.get('subject','General')} • {r.get('level','Beginner')} • {due_txt}")
201
+ st.progress(_progress_0_1(pct))
202
+
203
+ c1, c2 = st.columns(2)
204
+ with c1:
205
+ # pass lesson & assignment to the Lessons page
206
+ if st.button("▶️ Start Lesson", key=f"start_lesson_{r.get('assignment_id')}"):
207
+ st.session_state.selected_lesson = r.get("lesson_id")
208
+ st.session_state.selected_assignment = r.get("assignment_id")
209
+ st.session_state.current_page = "Lessons"
210
+ st.rerun()
211
+ with c2:
212
+ st.write(f"Status: **{status}**")
213
+
214
+ with quizzes_tab:
215
+ any_quiz = False
216
+ for r in rows:
217
+ quiz_id = r.get("quiz_id")
218
+ if not quiz_id:
219
+ continue
220
+ any_quiz = True
221
+
222
+ st.subheader(r.get("title", "Untitled"))
223
+ score, total = r.get("score"), r.get("total")
224
+ if score is not None and total:
225
+ try:
226
+ pct = int(round(100 * float(score) / float(total)))
227
+ st.caption(f"Last score: {pct}%")
228
+ except Exception:
229
+ st.caption("Last score: —")
230
+ else:
231
+ st.caption("No submission yet")
232
+
233
+ # pass quiz & assignment to the Quiz page
234
+ if st.button("📝 Start Quiz", key=f"start_quiz_{class_id}_{quiz_id}"):
235
+ st.session_state.selected_quiz = quiz_id
236
+ st.session_state.current_assignment = r.get("assignment_id")
237
+ st.session_state.current_page = "Quiz"
238
+ st.rerun()
239
+
240
+ if not any_quiz:
241
+ st.info("No quizzes yet for this class.")
242
+
243
+ st.markdown("---")
phase/Teacher_view/classmanage.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Teacher_view/classmanage.py
2
+ import os
3
+ import streamlit as st
4
+
5
+ from utils import db as dbapi
6
+ import utils.api as api # backend Space client
7
+
8
+ # When DISABLE_DB=1 (default), skip direct MySQL and use backend APIs
9
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
10
+
11
+
12
+ def _metric_card(label: str, value: str, caption: str = ""):
13
+ st.markdown(
14
+ f"""
15
+ <div class="metric-card">
16
+ <div class="metric-value">{value}</div>
17
+ <div class="metric-label">{label}</div>
18
+ <div class="metric-caption">{caption}</div>
19
+ </div>
20
+ """,
21
+ unsafe_allow_html=True,
22
+ )
23
+
24
+
25
+ def _prefer_db(db_name: str, api_func, default, *args, **kwargs):
26
+ """
27
+ Try local DB function if enabled & present; else call backend API; else return default.
28
+ """
29
+ if USE_LOCAL_DB and hasattr(dbapi, db_name):
30
+ try:
31
+ return getattr(dbapi, db_name)(*args, **kwargs)
32
+ except Exception as e:
33
+ st.warning(f"DB call {db_name} failed; falling back to backend. ({e})")
34
+ try:
35
+ return api_func(*args, **kwargs)
36
+ except Exception as e:
37
+ st.error(f"Backend call failed: {e}")
38
+ return default
39
+
40
+
41
+ def show_page():
42
+ user = st.session_state.user
43
+ teacher_id = user["user_id"]
44
+
45
+ st.title("📚 Classroom Management")
46
+ st.caption("Manage all your classrooms and students")
47
+
48
+ # -------- Create Classroom --------
49
+ with st.expander("➕ Create Classroom", expanded=False):
50
+ new_name = st.text_input("Classroom Name", key="new_classroom_name")
51
+ if st.button("Create Classroom"):
52
+ name = new_name.strip()
53
+ if not name:
54
+ st.error("Enter a real name, not whitespace.")
55
+ else:
56
+ out = _prefer_db(
57
+ "create_class",
58
+ lambda tid, n: api.create_class(tid, n),
59
+ None,
60
+ teacher_id, # positional arg
61
+ name, # positional arg
62
+ )
63
+ if out:
64
+ st.session_state.selected_class_id = out.get("class_id") or out.get("id")
65
+ st.success(f'Classroom "{name}" created with code: {out.get("code","—")}')
66
+ st.rerun()
67
+ else:
68
+ st.error("Could not create classroom (no response).")
69
+
70
+ # -------- Load classes for this teacher --------
71
+ classes = _prefer_db(
72
+ "list_classes_by_teacher",
73
+ lambda tid: api.list_classes_by_teacher(tid),
74
+ [],
75
+ teacher_id, # positional
76
+ )
77
+
78
+ if not classes:
79
+ st.info("No classrooms yet. Create one above, then share the code.")
80
+ return
81
+
82
+ # Picker
83
+ st.subheader("Your Classrooms")
84
+ options = {f"{c.get('name','(unnamed)')} (Code: {c.get('code','')})": c for c in classes}
85
+ selected_label = st.selectbox("Select a classroom", list(options.keys()))
86
+ selected = options[selected_label]
87
+ class_id = selected.get("class_id") or selected.get("id")
88
+
89
+ st.markdown("---")
90
+ st.header(selected.get("name", "Classroom"))
91
+
92
+ # -------- Code stripe --------
93
+ st.subheader("Class Code")
94
+ c1, c2, c3 = st.columns([3, 1, 1])
95
+ with c1:
96
+ st.markdown(f"**`{selected.get('code', 'UNKNOWN')}`**")
97
+ with c2:
98
+ if st.button("📋 Copy Code"):
99
+ st.toast("Code is shown above. Copy it.")
100
+ with c3:
101
+ st.button("🗑️ Delete Class", disabled=True, help="Soft-delete coming later")
102
+
103
+ # -------- Tabs --------
104
+ tab_students, tab_content, tab_analytics = st.tabs(["👥 Students", "📘 Content", "📊 Analytics"])
105
+
106
+ # ============== Students tab ==============
107
+ with tab_students:
108
+ q = st.text_input("Search students by name or email", "")
109
+ roster = _prefer_db(
110
+ "list_students_in_class",
111
+ lambda cid: api.list_students_in_class(cid),
112
+ [],
113
+ class_id, # positional
114
+ )
115
+
116
+ # simple filter
117
+ if q.strip():
118
+ ql = q.lower()
119
+ roster = [r for r in roster if ql in (r.get("name","").lower()) or ql in (r.get("email","").lower())]
120
+
121
+ st.caption(f"{len(roster)} Students Found")
122
+
123
+ if not roster:
124
+ st.info("No students in this class yet.")
125
+ else:
126
+ for s in roster:
127
+ st.subheader(f"👤 {s.get('name','(unknown)')}")
128
+ st.caption(s.get("email","—"))
129
+ joined = s.get("joined_at") or s.get("created_at")
130
+ st.caption(f"📅 Joined: {str(joined)[:10] if joined else '—'}")
131
+ st.progress(0.0) # placeholder bar
132
+ cols = st.columns(3)
133
+ level_slug = (s.get("level_slug") or s.get("level") or "beginner")
134
+ try:
135
+ level_label = level_slug.capitalize() if isinstance(level_slug, str) else str(level_slug)
136
+ except Exception:
137
+ level_label = "—"
138
+ cols[0].metric("⭐ Level", level_label)
139
+ cols[1].metric("📊 Avg Score", "—")
140
+ cols[2].metric("🔥 Streak", "—")
141
+ st.markdown("---")
142
+
143
+ # ============== Content tab ==============
144
+ with tab_content:
145
+ counts = _prefer_db(
146
+ "class_content_counts",
147
+ lambda cid: api.class_content_counts(cid),
148
+ {"lessons": 0, "quizzes": 0},
149
+ class_id, # positional
150
+ )
151
+ left, right = st.columns(2)
152
+ with left:
153
+ _metric_card("📖 Custom Lessons", str(counts.get("lessons", 0)), "Lessons created for this classroom")
154
+ with right:
155
+ _metric_card("🏆 Custom Quizzes", str(counts.get("quizzes", 0)), "Quizzes created for this classroom")
156
+
157
+ assigs = _prefer_db(
158
+ "list_class_assignments",
159
+ lambda cid: api.list_class_assignments(cid),
160
+ [],
161
+ class_id, # positional
162
+ )
163
+ if assigs:
164
+ st.markdown("#### Assigned items")
165
+ for a in assigs:
166
+ has_quiz = " + Quiz" if a.get("quiz_id") else ""
167
+ st.markdown(f"- **{a.get('title','Untitled')}** · {a.get('subject','—')} · {a.get('level','—')}{has_quiz}")
168
+
169
+ # ============== Analytics tab ==============
170
+ with tab_analytics:
171
+ stats = _prefer_db(
172
+ "class_analytics",
173
+ lambda cid: api.class_analytics(cid),
174
+ {"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0},
175
+ class_id, # positional
176
+ )
177
+
178
+ class_avg_pct = round(float(stats.get("class_avg", 0)) * 100) if stats.get("class_avg") is not None else 0
179
+ total_xp = stats.get("total_xp", 0)
180
+ lessons_completed = stats.get("lessons_completed", 0)
181
+
182
+ g1, g2, g3 = st.columns(3)
183
+ with g1:
184
+ _metric_card("📊 Class Average", f"{class_avg_pct}%", "Average quiz performance")
185
+ with g2:
186
+ _metric_card("🪙 Total XP", f"{total_xp}", "Combined XP earned")
187
+ with g3:
188
+ _metric_card("📘 Lessons Completed", f"{lessons_completed}", "Total lessons completed")
phase/Teacher_view/contentmanage.py ADDED
@@ -0,0 +1,688 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Teacher_view/contentmanage.py
2
+ import json
3
+ import os
4
+ from datetime import datetime
5
+ import streamlit as st
6
+ from utils import db as dbapi
7
+ import utils.api as api # backend Space client
8
+
9
+ # Switch automatically: if DISABLE_DB=1 (default), use backend API; else use local DB
10
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
11
+
12
+ # ---------- small UI helpers ----------
13
+ def _pill(text):
14
+ return f"<span style='background:#eef6ff;border:1px solid #cfe3ff;border-radius:999px;padding:2px 8px;font-size:12px;margin-right:6px'>{text}</span>"
15
+
16
+ def _progress(val: float):
17
+ pct = max(0, min(100, int(round(val * 100))))
18
+ return f"""
19
+ <div style="height:8px;background:#eef2ff;border-radius:999px;overflow:hidden">
20
+ <div style="width:{pct}%;height:100%;background:#3b82f6"></div>
21
+ </div>
22
+ """
23
+
24
+ def _fmt_date(v):
25
+ if isinstance(v, datetime):
26
+ return v.strftime("%Y-%m-%d")
27
+ try:
28
+ s = str(v)
29
+ return s[:10]
30
+ except Exception:
31
+ return ""
32
+
33
+ # ---------- Quiz generator via backend LLM (llama 3.1 8B) ----------
34
+ def _generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
35
+ """
36
+ Calls your backend, which uses GEN_MODEL (llama-3.1-8b-instruct).
37
+ Returns a normalized list like:
38
+ [{"question":"...","options":["A","B","C","D"],"answer_key":"B","points":1}, ...]
39
+ """
40
+ def _normalize(items):
41
+ out = []
42
+ for it in (items or [])[:n_questions]:
43
+ q = str(it.get("question", "")).strip()
44
+ opts = it.get("options", [])
45
+ if not q or not isinstance(opts, list):
46
+ continue
47
+ while len(opts) < 4:
48
+ opts.append("Option")
49
+ opts = opts[:4]
50
+ key = str(it.get("answer_key", "A")).strip().upper()[:1]
51
+ if key not in ("A","B","C","D"):
52
+ key = "A"
53
+ out.append({"question": q, "options": opts, "answer_key": key, "points": 1})
54
+ return out
55
+
56
+ try:
57
+ resp = api.generate_quiz_from_text(content, n_questions=n_questions, subject=subject, level=level)
58
+ items = resp.get("items", resp) # allow backend to return either shape
59
+ return _normalize(items)
60
+ except Exception as e:
61
+ with st.expander("Quiz generation error details"):
62
+ st.code(str(e))
63
+ st.warning("Quiz generation failed via backend. Check the /quiz/generate endpoint and GEN_MODEL.")
64
+ return []
65
+
66
+ # ---------- Thin wrappers that choose DB or Backend ----------
67
+ def _list_classes_by_teacher(teacher_id: int):
68
+ if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"):
69
+ return dbapi.list_classes_by_teacher(teacher_id)
70
+ try:
71
+ return api.list_classes_by_teacher(teacher_id)
72
+ except Exception:
73
+ return []
74
+
75
+ def _list_all_students_for_teacher(teacher_id: int):
76
+ if USE_LOCAL_DB and hasattr(dbapi, "list_all_students_for_teacher"):
77
+ return dbapi.list_all_students_for_teacher(teacher_id)
78
+ try:
79
+ return api.list_all_students_for_teacher(teacher_id)
80
+ except Exception:
81
+ return []
82
+
83
+ def _list_lessons_by_teacher(teacher_id: int):
84
+ if USE_LOCAL_DB and hasattr(dbapi, "list_lessons_by_teacher"):
85
+ return dbapi.list_lessons_by_teacher(teacher_id)
86
+ try:
87
+ return api.list_lessons_by_teacher(teacher_id)
88
+ except Exception:
89
+ return []
90
+
91
+ def _list_quizzes_by_teacher(teacher_id: int):
92
+ if USE_LOCAL_DB and hasattr(dbapi, "list_quizzes_by_teacher"):
93
+ return dbapi.list_quizzes_by_teacher(teacher_id)
94
+ try:
95
+ return api.list_quizzes_by_teacher(teacher_id)
96
+ except Exception:
97
+ return []
98
+
99
+ def _create_lesson(teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
100
+ if USE_LOCAL_DB and hasattr(dbapi, "create_lesson"):
101
+ return dbapi.create_lesson(teacher_id, title, description, subject, level, sections)
102
+ return api.create_lesson(teacher_id, title, description, subject, level, sections)
103
+
104
+ def _update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
105
+ if USE_LOCAL_DB and hasattr(dbapi, "update_lesson"):
106
+ return dbapi.update_lesson(lesson_id, teacher_id, title, description, subject, level, sections)
107
+ return api.update_lesson(lesson_id, teacher_id, title, description, subject, level, sections)
108
+
109
+ def _delete_lesson(lesson_id: int, teacher_id: int):
110
+ if USE_LOCAL_DB and hasattr(dbapi, "delete_lesson"):
111
+ return dbapi.delete_lesson(lesson_id, teacher_id)
112
+ return api.delete_lesson(lesson_id, teacher_id)
113
+
114
+ def _get_lesson(lesson_id: int):
115
+ if USE_LOCAL_DB and hasattr(dbapi, "get_lesson"):
116
+ return dbapi.get_lesson(lesson_id)
117
+ return api.get_lesson(lesson_id)
118
+
119
+ def _create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
120
+ if USE_LOCAL_DB and hasattr(dbapi, "create_quiz"):
121
+ return dbapi.create_quiz(lesson_id, title, items, settings)
122
+ return api.create_quiz(lesson_id, title, items, settings)
123
+
124
+ def _update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict):
125
+ if USE_LOCAL_DB and hasattr(dbapi, "update_quiz"):
126
+ return dbapi.update_quiz(quiz_id, teacher_id, title, items, settings)
127
+ return api.update_quiz(quiz_id, teacher_id, title, items, settings)
128
+
129
+ def _delete_quiz(quiz_id: int, teacher_id: int):
130
+ if USE_LOCAL_DB and hasattr(dbapi, "delete_quiz"):
131
+ return dbapi.delete_quiz(quiz_id, teacher_id)
132
+ return api.delete_quiz(quiz_id, teacher_id)
133
+
134
+ def _list_assigned_students_for_lesson(lesson_id: int):
135
+ if USE_LOCAL_DB and hasattr(dbapi, "list_assigned_students_for_lesson"):
136
+ return dbapi.list_assigned_students_for_lesson(lesson_id)
137
+ return api.list_assigned_students_for_lesson(lesson_id)
138
+
139
+ def _list_assigned_students_for_quiz(quiz_id: int):
140
+ if USE_LOCAL_DB and hasattr(dbapi, "list_assigned_students_for_quiz"):
141
+ return dbapi.list_assigned_students_for_quiz(quiz_id)
142
+ return api.list_assigned_students_for_quiz(quiz_id)
143
+
144
+ def _assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int):
145
+ if USE_LOCAL_DB and hasattr(dbapi, "assign_to_class"):
146
+ return dbapi.assign_to_class(lesson_id, quiz_id, class_id, teacher_id)
147
+ return api.assign_to_class(lesson_id, quiz_id, class_id, teacher_id)
148
+
149
+ # ---------- Create panels ----------
150
+ def _create_lesson_panel(teacher_id: int):
151
+ st.markdown("### ✍️ Create New Lesson")
152
+
153
+ classes = _list_classes_by_teacher(teacher_id)
154
+ class_opts = {f"{c['name']} (code {c['code']})": c["class_id"] for c in classes} if classes else {}
155
+
156
+ if "cl_topic_count" not in st.session_state:
157
+ st.session_state.cl_topic_count = 2 # start with two topics
158
+
159
+ cols_btn = st.columns([1,1,6])
160
+ with cols_btn[0]:
161
+ if st.button("➕ Add topic", type="secondary"):
162
+ st.session_state.cl_topic_count = min(20, st.session_state.cl_topic_count + 1)
163
+ st.rerun()
164
+ with cols_btn[1]:
165
+ if st.button("➖ Remove last", type="secondary", disabled=st.session_state.cl_topic_count <= 1):
166
+ st.session_state.cl_topic_count = max(1, st.session_state.cl_topic_count - 1)
167
+ st.rerun()
168
+
169
+ with st.form("create_lesson_form", clear_on_submit=False):
170
+ c1, c2 = st.columns([2,1])
171
+ title = c1.text_input("Title", placeholder="e.g., Jamaican Money Recognition")
172
+ level = c2.selectbox("Level", ["beginner","intermediate","advanced"], index=0)
173
+ description = st.text_area("Short description")
174
+ subject = st.selectbox("Subject", ["numeracy","finance"], index=0)
175
+
176
+ st.markdown("#### Topics")
177
+ topic_rows = []
178
+ for i in range(1, st.session_state.cl_topic_count + 1):
179
+ with st.expander(f"Topic {i}", expanded=True if i <= 2 else False):
180
+ t = st.text_input(f"Topic {i} title", key=f"t_title_{i}")
181
+ b = st.text_area(f"Topic {i} content", key=f"t_body_{i}", height=150)
182
+ topic_rows.append((t, b))
183
+
184
+ add_summary = st.checkbox("Append a Summary section at the end", value=True)
185
+ summary_text = ""
186
+ if add_summary:
187
+ summary_text = st.text_area(
188
+ "Summary notes",
189
+ key="summary_notes",
190
+ height=120,
191
+ placeholder="Key ideas, local examples, common mistakes, quick recap..."
192
+ )
193
+
194
+ st.markdown("#### Assign to class (optional)")
195
+ assign_classes = st.multiselect("Choose one or more classes", list(class_opts.keys()))
196
+
197
+ st.markdown("#### Auto-generate a quiz from this lesson (optional)")
198
+ gen_quiz = st.checkbox("Generate a quiz from content", value=False)
199
+ q_count = st.slider("", 3, 10, 5)
200
+
201
+ submitted = st.form_submit_button("Create lesson", type="primary")
202
+
203
+ if not submitted:
204
+ return
205
+
206
+ sections = []
207
+ for t, b in topic_rows:
208
+ if (t or b):
209
+ sections.append({"title": t or "Topic", "content": b or ""})
210
+
211
+ if add_summary:
212
+ sections.append({
213
+ "title": "Summary",
214
+ "content": (summary_text or "Write a short recap of the most important ideas.").strip()
215
+ })
216
+
217
+ if not title or not sections:
218
+ st.error("Please add a title and at least one topic.")
219
+ return
220
+
221
+ # create lesson (DB or backend)
222
+ try:
223
+ lesson_id = _create_lesson(teacher_id, title, description, subject, level, sections)
224
+ st.success(f"✅ Lesson created (ID {lesson_id}).")
225
+ except Exception as e:
226
+ st.error(f"Failed to create lesson: {e}")
227
+ return
228
+
229
+ # assign to chosen classes (lesson only for now)
230
+ for label in assign_classes:
231
+ try:
232
+ _assign_to_class(lesson_id, None, class_opts[label], teacher_id)
233
+ except Exception as e:
234
+ st.warning(f"Could not assign to {label}: {e}")
235
+
236
+ # auto-generate quiz via backend LLM
237
+ if gen_quiz:
238
+ text = "\n\n".join([s["title"] + "\n" + (s["content"] or "") for s in sections])
239
+ with st.spinner("Generating quiz from lesson content..."):
240
+ items = _generate_quiz_from_text(text, n_questions=q_count, subject=subject, level=level)
241
+ if items:
242
+ try:
243
+ qid = _create_quiz(lesson_id, f"{title} - Quiz", items, {})
244
+ st.success(f"🧠 Quiz generated and saved (ID {qid}).")
245
+ for label in assign_classes:
246
+ _assign_to_class(lesson_id, qid, class_opts[label], teacher_id)
247
+ except Exception as e:
248
+ st.warning(f"Lesson saved, but failed to save quiz: {e}")
249
+
250
+ st.session_state.show_create_lesson = False
251
+ st.rerun()
252
+
253
+ def _create_quiz_panel(teacher_id: int):
254
+ st.markdown("### 🏆 Create New Quiz")
255
+
256
+ lessons = _list_lessons_by_teacher(teacher_id)
257
+ lesson_map = {f"{L['title']} (#{L['lesson_id']})": L["lesson_id"] for L in lessons}
258
+ if not lesson_map:
259
+ st.info("Create a lesson first, then link a quiz to it.")
260
+ return
261
+
262
+ if "cq_q_count" not in st.session_state:
263
+ st.session_state.cq_q_count = 5
264
+
265
+ with st.form("create_quiz_form", clear_on_submit=False):
266
+ c1, c2 = st.columns([2,1])
267
+ title = c1.text_input("Title", placeholder="e.g., Currency Basics Quiz")
268
+ lesson_label = c2.selectbox("Linked Lesson", list(lesson_map.keys()))
269
+
270
+ st.markdown("#### Questions (up to 10)")
271
+ items = []
272
+ for i in range(1, st.session_state.cq_q_count + 1):
273
+ with st.expander(f"Question {i}", expanded=(i <= 2)):
274
+ q = st.text_area(f"Prompt {i}", key=f"q_{i}")
275
+ cA, cB = st.columns(2)
276
+ a = cA.text_input(f"Option A (correct?)", key=f"optA_{i}")
277
+ b = cB.text_input(f"Option B", key=f"optB_{i}")
278
+ cC, cD = st.columns(2)
279
+ c = cC.text_input(f"Option C", key=f"optC_{i}")
280
+ d = cD.text_input(f"Option D", key=f"optD_{i}")
281
+ correct = st.radio("Correct answer", ["A","B","C","D"], index=0, key=f"ans_{i}", horizontal=True)
282
+ items.append({"question": q, "options": [a,b,c,d], "answer_key": correct, "points": 1})
283
+
284
+ row = st.columns([1,1,4,2])
285
+ with row[0]:
286
+ if st.form_submit_button("➕ Add question", type="secondary", disabled=st.session_state.cq_q_count >= 10):
287
+ st.session_state.cq_q_count = min(10, st.session_state.cq_q_count + 1)
288
+ st.rerun()
289
+ with row[1]:
290
+ if st.form_submit_button("➖ Remove last", type="secondary", disabled=st.session_state.cq_q_count <= 1):
291
+ st.session_state.cq_q_count = max(1, st.session_state.cq_q_count - 1)
292
+ st.rerun()
293
+
294
+ submitted = row[3].form_submit_button("Create quiz", type="primary")
295
+
296
+ if not submitted:
297
+ return
298
+ if not title:
299
+ st.error("Please add a quiz title.")
300
+ return
301
+
302
+ cleaned = []
303
+ for it in items:
304
+ q = (it["question"] or "").strip()
305
+ opts = [o for o in it["options"] if (o or "").strip()]
306
+ if len(opts) < 2 or not q:
307
+ continue
308
+ while len(opts) < 4:
309
+ opts.append("Option")
310
+ cleaned.append({"question": q, "options": opts[:4], "answer_key": it["answer_key"], "points": 1})
311
+
312
+ if not cleaned:
313
+ st.error("Add at least one valid question.")
314
+ return
315
+
316
+ try:
317
+ qid = _create_quiz(lesson_map[lesson_label], title, cleaned, {})
318
+ st.success(f"✅ Quiz created (ID {qid}).")
319
+ st.session_state.show_create_quiz = False
320
+ st.rerun()
321
+ except Exception as e:
322
+ st.error(f"Failed to create quiz: {e}")
323
+
324
+ def _edit_lesson_panel(teacher_id: int, lesson_id: int):
325
+ try:
326
+ data = _get_lesson(lesson_id)
327
+ except Exception as e:
328
+ st.error(f"Could not load lesson #{lesson_id}: {e}")
329
+ return
330
+
331
+ L = data.get("lesson", {})
332
+ secs = data.get("sections", []) or []
333
+
334
+ key_cnt = f"el_cnt_{lesson_id}"
335
+ if key_cnt not in st.session_state:
336
+ st.session_state[key_cnt] = max(1, len(secs))
337
+
338
+ st.markdown("### ✏️ Edit Lesson")
339
+
340
+ tools = st.columns([1,1,8])
341
+ with tools[0]:
342
+ if st.button("➕ Add section", key=f"el_add_{lesson_id}", use_container_width=True):
343
+ st.session_state[key_cnt] = min(50, st.session_state[key_cnt] + 1)
344
+ st.rerun()
345
+ with tools[1]:
346
+ if st.button("➖ Remove last", key=f"el_rem_{lesson_id}",
347
+ disabled=st.session_state[key_cnt] <= 1, use_container_width=True):
348
+ st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
349
+ st.rerun()
350
+
351
+ with st.form(f"edit_lesson_form_{lesson_id}", clear_on_submit=False):
352
+ c1, c2 = st.columns([2,1])
353
+ title = c1.text_input("Title", value=L.get("title") or "")
354
+ level = c2.selectbox(
355
+ "Level",
356
+ ["beginner","intermediate","advanced"],
357
+ index=["beginner","intermediate","advanced"].index(L.get("level") or "beginner")
358
+ )
359
+ description = st.text_area("Short description", value=L.get("description") or "")
360
+ subject = st.selectbox("Subject", ["numeracy","finance"], index=(0 if (L.get("subject")=="numeracy") else 1))
361
+
362
+ st.markdown("#### Sections")
363
+ edited_sections = []
364
+ total = st.session_state[key_cnt]
365
+ for i in range(1, total + 1):
366
+ s = secs[i-1] if i-1 < len(secs) else {"title":"", "content":""}
367
+ with st.expander(f"Section {i}", expanded=(i <= 2)):
368
+ t = st.text_input(f"Title {i}", value=s.get("title") or "", key=f"el_t_{lesson_id}_{i}")
369
+ b = st.text_area(f"Content {i}", value=s.get("content") or "", height=150, key=f"el_b_{lesson_id}_{i}")
370
+ edited_sections.append({"title": t or "Section", "content": b or ""})
371
+
372
+ save = st.form_submit_button("💾 Save changes", type="primary", use_container_width=True)
373
+
374
+ actions = st.columns([8,2])
375
+ with actions[1]:
376
+ cancel_clicked = st.button("✖ Cancel", key=f"el_cancel_{lesson_id}", type="secondary", use_container_width=True)
377
+
378
+ if cancel_clicked:
379
+ st.session_state.show_edit_lesson = False
380
+ st.session_state.edit_lesson_id = None
381
+ st.rerun()
382
+
383
+ if not save:
384
+ return
385
+
386
+ if not title or not any((s["title"] or s["content"]).strip() for s in edited_sections):
387
+ st.error("Title and at least one non-empty section are required.")
388
+ return
389
+
390
+ ok = False
391
+ try:
392
+ ok = _update_lesson(lesson_id, teacher_id, title, description, subject, level, edited_sections)
393
+ except Exception as e:
394
+ st.error(f"Update failed: {e}")
395
+
396
+ if ok:
397
+ st.success("✅ Lesson updated.")
398
+ st.session_state.show_edit_lesson = False
399
+ st.session_state.edit_lesson_id = None
400
+ st.rerun()
401
+ else:
402
+ st.error("Could not update this lesson. Check ownership or backend errors.")
403
+
404
+ def _edit_quiz_panel(teacher_id: int, quiz_id: int):
405
+ # Load quiz
406
+ try:
407
+ data = (dbapi.get_quiz(quiz_id) if (USE_LOCAL_DB and hasattr(dbapi, "get_quiz")) else api._req("GET", f"/quizzes/{quiz_id}").json())
408
+ except Exception as e:
409
+ st.error(f"Quiz not found: {e}")
410
+ return
411
+
412
+ Q = data.get("quiz")
413
+ raw_items = data.get("items", [])
414
+ if not Q:
415
+ st.error("Quiz not found.")
416
+ return
417
+
418
+ def _dec(x):
419
+ if isinstance(x, str):
420
+ try:
421
+ return json.loads(x)
422
+ except Exception:
423
+ return x
424
+ return x
425
+
426
+ items = []
427
+ for it in raw_items:
428
+ opts = _dec(it.get("options")) or []
429
+ while len(opts) < 4:
430
+ opts.append("Option")
431
+ opts = opts[:4]
432
+
433
+ ans = _dec(it.get("answer_key"))
434
+ if isinstance(ans, list) and ans:
435
+ ans = ans[0]
436
+ ans = (str(ans) or "A").upper()[:1]
437
+ if ans not in ("A","B","C","D"):
438
+ ans = "A"
439
+
440
+ items.append({
441
+ "question": (it.get("question") or "").strip(),
442
+ "options": opts,
443
+ "answer_key": ans,
444
+ "points": int(it.get("points") or 1),
445
+ })
446
+
447
+ key_cnt = f"eq_cnt_{quiz_id}"
448
+ if key_cnt not in st.session_state:
449
+ st.session_state[key_cnt] = max(1, len(items) or 5)
450
+
451
+ st.markdown("### ✏️ Edit Quiz")
452
+
453
+ with st.form(f"edit_quiz_form_{quiz_id}", clear_on_submit=False):
454
+ title = st.text_input("Title", value=Q.get("title") or f"Quiz #{quiz_id}")
455
+
456
+ edited = []
457
+ total = st.session_state[key_cnt]
458
+ for i in range(1, total + 1):
459
+ it = items[i-1] if i-1 < len(items) else {"question":"", "options":["","","",""], "answer_key":"A", "points":1}
460
+ with st.expander(f"Question {i}", expanded=(i <= 2)):
461
+ q = st.text_area(f"Prompt {i}", value=it["question"], key=f"eq_q_{quiz_id}_{i}")
462
+ cA, cB = st.columns(2)
463
+ a = cA.text_input(f"Option A", value=it["options"][0], key=f"eq_A_{quiz_id}_{i}")
464
+ b = cB.text_input(f"Option B", value=it["options"][1], key=f"eq_B_{quiz_id}_{i}")
465
+ cC, cD = st.columns(2)
466
+ c = cC.text_input(f"Option C", value=it["options"][2], key=f"eq_C_{quiz_id}_{i}")
467
+ d = cD.text_input(f"Option D", value=it["options"][3], key=f"eq_D_{quiz_id}_{i}")
468
+ correct = st.radio("Correct answer", ["A","B","C","D"],
469
+ index=["A","B","C","D"].index(it["answer_key"]),
470
+ key=f"eq_ans_{quiz_id}_{i}", horizontal=True)
471
+ edited.append({"question": q, "options": [a,b,c,d], "answer_key": correct, "points": 1})
472
+
473
+ row = st.columns([1,1,6,2,2])
474
+ with row[0]:
475
+ if st.form_submit_button("➕ Add question", type="secondary"):
476
+ st.session_state[key_cnt] = min(20, st.session_state[key_cnt] + 1)
477
+ st.rerun()
478
+ with row[1]:
479
+ if st.form_submit_button("➖ Remove last", type="secondary", disabled=st.session_state[key_cnt] <= 1):
480
+ st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
481
+ st.rerun()
482
+
483
+ save = row[3].form_submit_button("💾 Save", type="primary")
484
+ cancel = row[4].form_submit_button("✖ Cancel", type="secondary")
485
+
486
+ if cancel:
487
+ st.session_state.show_edit_quiz = False
488
+ st.session_state.edit_quiz_id = None
489
+ st.rerun()
490
+
491
+ if not save:
492
+ return
493
+
494
+ cleaned = []
495
+ for it in edited:
496
+ q = (it["question"] or "").strip()
497
+ opts = [o for o in it["options"] if (o or "").strip()]
498
+ if not q or len(opts) < 2:
499
+ continue
500
+ while len(opts) < 4:
501
+ opts.append("Option")
502
+ cleaned.append({
503
+ "question": q,
504
+ "options": opts[:4],
505
+ "answer_key": it["answer_key"],
506
+ "points": 1
507
+ })
508
+
509
+ if not title or not cleaned:
510
+ st.error("Title and at least one valid question are required.")
511
+ return
512
+
513
+ ok = False
514
+ try:
515
+ ok = _update_quiz(quiz_id, teacher_id, title, cleaned, settings={})
516
+ except Exception as e:
517
+ st.error(f"Save failed: {e}")
518
+
519
+ if ok:
520
+ st.success("✅ Quiz updated.")
521
+ st.session_state.show_edit_quiz = False
522
+ st.session_state.edit_quiz_id = None
523
+ st.rerun()
524
+ else:
525
+ st.error("Could not update this quiz. Check ownership or backend errors.")
526
+
527
+ # ---------- Main page ----------
528
+ def show_page():
529
+ user = st.session_state.user
530
+ teacher_id = user["user_id"]
531
+
532
+ st.title("📚 Content Management")
533
+ st.caption("Create and manage custom lessons and quizzes")
534
+
535
+ # preload lists
536
+ lessons = _list_lessons_by_teacher(teacher_id)
537
+ quizzes = _list_quizzes_by_teacher(teacher_id)
538
+
539
+ # top action bar
540
+ a1, a2, _sp = st.columns([3,3,4])
541
+ if a1.button("➕ Create Lesson", use_container_width=True):
542
+ st.session_state.show_create_lesson = True
543
+ if a2.button("🏆 Create Quiz", use_container_width=True):
544
+ st.session_state.show_create_quiz = True
545
+
546
+ # create panels
547
+ if st.session_state.get("show_create_lesson"):
548
+ with st.container(border=True):
549
+ _create_lesson_panel(teacher_id)
550
+ st.markdown("---")
551
+
552
+ if st.session_state.get("show_create_quiz"):
553
+ with st.container(border=True):
554
+ _create_quiz_panel(teacher_id)
555
+ st.markdown("---")
556
+
557
+ # inline editors
558
+ if st.session_state.get("show_edit_lesson") and st.session_state.get("edit_lesson_id"):
559
+ with st.container(border=True):
560
+ _edit_lesson_panel(teacher_id, st.session_state.edit_lesson_id)
561
+ st.markdown("---")
562
+
563
+ if st.session_state.get("show_edit_quiz") and st.session_state.get("edit_quiz_id"):
564
+ with st.container(border=True):
565
+ _edit_quiz_panel(teacher_id, st.session_state.edit_quiz_id)
566
+ st.markdown("---")
567
+
568
+ # Tabs
569
+ tab1, tab2 = st.tabs([f"Custom Lessons ({len(lessons)})", f"Custom Quizzes ({len(quizzes)})"])
570
+
571
+ # ========== LESSONS ==========
572
+ with tab1:
573
+ if not lessons:
574
+ st.info("No lessons yet. Use **Create Lesson** above.")
575
+ else:
576
+ all_students = _list_all_students_for_teacher(teacher_id)
577
+ student_options = {f"{s['name']} · {s['email']}": s["user_id"] for s in all_students}
578
+
579
+ for L in lessons:
580
+ assignees = _list_assigned_students_for_lesson(L["lesson_id"])
581
+ assignee_names = [a.get("name") for a in assignees]
582
+ created = _fmt_date(L.get("created_at"))
583
+ count = len(assignees)
584
+
585
+ with st.container(border=True):
586
+ c1, c2 = st.columns([8,3])
587
+ with c1:
588
+ st.markdown(f"### {L['title']}")
589
+ st.caption(L.get("description") or "")
590
+ st.markdown(
591
+ _pill((L.get("level") or "beginner").capitalize()) +
592
+ _pill(L.get("subject","finance")) +
593
+ _pill(f"{count} student{'s' if count != 1 else ''} assigned") +
594
+ _pill(f"Created {created}"),
595
+ unsafe_allow_html=True
596
+ )
597
+ with c2:
598
+ b1, b2 = st.columns([1,1])
599
+ with b1:
600
+ if st.button("Edit", key=f"edit_{L['lesson_id']}"):
601
+ st.session_state.edit_lesson_id = L["lesson_id"]
602
+ st.session_state.show_edit_lesson = True
603
+ st.rerun()
604
+ with b2:
605
+ if st.button("Delete", key=f"del_{L['lesson_id']}"):
606
+ ok, msg = _delete_lesson(L["lesson_id"], teacher_id)
607
+ if ok: st.success("Lesson deleted"); st.rerun()
608
+ else: st.error(msg or "Delete failed")
609
+
610
+ st.markdown("**Assigned Students:**")
611
+ if assignee_names:
612
+ st.markdown(" ".join(_pill(n) for n in assignee_names if n), unsafe_allow_html=True)
613
+ else:
614
+ st.caption("No students assigned yet.")
615
+
616
+ # ========== QUIZZES ==========
617
+ with tab2:
618
+ if not quizzes:
619
+ st.info("No quizzes yet. Use **Create Quiz** above.")
620
+ else:
621
+ for Q in quizzes:
622
+ assignees = _list_assigned_students_for_quiz(Q["quiz_id"])
623
+ created = _fmt_date(Q.get("created_at"))
624
+ num_qs = int(Q.get("num_items", 0))
625
+
626
+ with st.container(border=True):
627
+ c1, c2 = st.columns([8,3])
628
+ with c1:
629
+ st.markdown(f"### {Q['title']}")
630
+ st.caption(f"Lesson: {Q.get('lesson_title','')}")
631
+ st.markdown(
632
+ _pill(f"{num_qs} question{'s' if num_qs != 1 else ''}") +
633
+ _pill(f"{len(assignees)} students assigned") +
634
+ _pill(f"Created {created}"),
635
+ unsafe_allow_html=True
636
+ )
637
+ with c2:
638
+ b1, b2 = st.columns(2)
639
+ with b1:
640
+ if st.button("Edit", key=f"editq_{Q['quiz_id']}"):
641
+ st.session_state.edit_quiz_id = Q["quiz_id"]
642
+ st.session_state.show_edit_quiz = True
643
+ st.rerun()
644
+ with b2:
645
+ if st.button("Delete", key=f"delq_{Q['quiz_id']}"):
646
+ ok, msg = _delete_quiz(Q["quiz_id"], teacher_id)
647
+ if ok: st.success("Quiz deleted"); st.rerun()
648
+ else: st.error(msg or "Delete failed")
649
+
650
+ st.markdown("**Assigned Students:**")
651
+ if assignees:
652
+ st.markdown(" ".join(_pill(a.get('name')) for a in assignees if a.get('name')), unsafe_allow_html=True)
653
+ else:
654
+ st.caption("No students assigned yet.")
655
+
656
+ with st.expander("View questions", expanded=False):
657
+ # Load items on demand to avoid heavy initial load
658
+ try:
659
+ data = (dbapi.get_quiz(Q["quiz_id"]) if (USE_LOCAL_DB and hasattr(dbapi, "get_quiz"))
660
+ else api._req("GET", f"/quizzes/{Q['quiz_id']}").json())
661
+ except Exception as e:
662
+ st.info(f"Could not fetch items: {e}")
663
+ data = None
664
+ items = data.get("items", []) if data else []
665
+ if not items:
666
+ st.info("No items found for this quiz.")
667
+ else:
668
+ labels = ["A","B","C","D"]
669
+ for i, it in enumerate(items, start=1):
670
+ opts = it.get("options")
671
+ if isinstance(opts, str):
672
+ try:
673
+ opts = json.loads(opts)
674
+ except Exception:
675
+ opts = [opts]
676
+ answer = it.get("answer_key")
677
+ if isinstance(answer, str):
678
+ try:
679
+ answer = json.loads(answer)
680
+ except Exception:
681
+ pass
682
+
683
+ st.markdown(f"**Q{i}.** {it.get('question','').strip()}")
684
+ for j, opt in enumerate((opts or [])[:4]):
685
+ st.write(f"{labels[j]}) {opt}")
686
+ ans_text = answer if isinstance(answer, str) else ",".join(answer or [])
687
+ st.caption(f"Answer: {ans_text}")
688
+ st.markdown("---")
phase/Teacher_view/studentlist.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Teacher_view/studentlist.py
2
+ import os
3
+ import streamlit as st
4
+ from utils import db as dbapi
5
+ import utils.api as api # backend Space client
6
+
7
+ # Use local DB only when DISABLE_DB != "1"
8
+ USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1"
9
+
10
+ # ---------- tiny helpers ----------
11
+ def _avatar(name: str) -> str:
12
+ return "🧑‍🎓" if hash(name) % 2 else "👩‍🎓"
13
+
14
+ def _avg_pct_from_row(r) -> int:
15
+ """
16
+ Accepts either:
17
+ - r['avg_pct'] in [0, 100]
18
+ - r['avg_score'] in [0, 1] or [0, 100]
19
+ Returns an int 0..100.
20
+ """
21
+ v = r.get("avg_pct", r.get("avg_score", 0)) or 0
22
+ try:
23
+ f = float(v)
24
+ if f <= 1.0: # treat as 0..1
25
+ f *= 100.0
26
+ return max(0, min(100, int(round(f))))
27
+ except Exception:
28
+ return 0
29
+
30
+ def _level_from_xp(total_xp: int) -> int:
31
+ try:
32
+ xp = int(total_xp or 0)
33
+ except Exception:
34
+ xp = 0
35
+ return 1 + xp // 500
36
+
37
+ def _report_text(r, level, avg_pct):
38
+ return (
39
+ "STUDENT PROGRESS REPORT\n"
40
+ "======================\n"
41
+ f"Student: {r.get('name','')}\n"
42
+ f"Email: {r.get('email','')}\n"
43
+ f"Joined: {str(r.get('joined_at',''))[:10]}\n\n"
44
+ "PROGRESS OVERVIEW\n"
45
+ "-----------------\n"
46
+ f"Lessons Completed: {int(r.get('lessons_completed') or 0)}/{int(r.get('total_assigned_lessons') or 0)}\n"
47
+ f"Average Quiz Score: {avg_pct}%\n"
48
+ f"Total XP: {int(r.get('total_xp') or 0)}\n"
49
+ f"Current Level: {level}\n"
50
+ f"Study Streak: {int(r.get('streak_days') or 0)} days\n"
51
+ )
52
+
53
+ ROW_CSS = """
54
+ <style>
55
+ .sm-chip{display:inline-block;padding:4px 10px;border-radius:999px;background:#eef7f1;color:#0b8f5d;font-weight:600;font-size:.80rem;margin-left:8px}
56
+ .sm-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid #e6e6e6;border-radius:8px;background:#fff}
57
+ .sm-row{border:1px solid #eee;border-radius:12px;padding:16px 16px;margin:10px 0;background:#fff}
58
+ .sm-row:hover{box-shadow:0 2px 10px rgba(0,0,0,.04)}
59
+ .sm-right{display:flex;gap:16px;align-items:center;justify-content:flex-end}
60
+ .sm-metric{min-width:90px;text-align:right}
61
+ .sm-metric .label{color:#777;font-size:.75rem}
62
+ .sm-metric .value{font-weight:700;font-size:1.1rem}
63
+ .sm-name{font-size:1.05rem;font-weight:700}
64
+ .sm-sub{color:#6c6c6c;font-size:.85rem}
65
+ </style>
66
+ """
67
+
68
+ # ---------- data access (DB or Backend) ----------
69
+ @st.cache_data(show_spinner=False, ttl=30)
70
+ def _list_classes_by_teacher(teacher_id: int):
71
+ if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"):
72
+ return dbapi.list_classes_by_teacher(teacher_id) or []
73
+ try:
74
+ return api.list_classes_by_teacher(teacher_id) or []
75
+ except Exception:
76
+ return []
77
+
78
+ @st.cache_data(show_spinner=False, ttl=30)
79
+ def _get_class(class_id: int):
80
+ if USE_LOCAL_DB and hasattr(dbapi, "get_class"):
81
+ return dbapi.get_class(class_id) or {}
82
+ try:
83
+ return api.get_class(class_id) or {}
84
+ except Exception:
85
+ return {}
86
+
87
+ @st.cache_data(show_spinner=False, ttl=30)
88
+ def _class_student_metrics(class_id: int):
89
+ if USE_LOCAL_DB and hasattr(dbapi, "class_student_metrics"):
90
+ return dbapi.class_student_metrics(class_id) or []
91
+ try:
92
+ return api.class_student_metrics(class_id) or []
93
+ except Exception:
94
+ return []
95
+
96
+ @st.cache_data(show_spinner=False, ttl=30)
97
+ def _list_assignments_for_student(student_id: int):
98
+ if USE_LOCAL_DB and hasattr(dbapi, "list_assignments_for_student"):
99
+ return dbapi.list_assignments_for_student(student_id) or []
100
+ try:
101
+ return api.list_assignments_for_student(student_id) or []
102
+ except Exception:
103
+ return []
104
+
105
+ # ---------- page ----------
106
+ def show_page():
107
+ st.title("🎓 Student Management")
108
+ st.caption("Monitor and manage your students' progress")
109
+ st.markdown(ROW_CSS, unsafe_allow_html=True)
110
+
111
+ teacher = st.session_state.get("user")
112
+ if not teacher:
113
+ st.error("Please log in.")
114
+ return
115
+ teacher_id = teacher["user_id"]
116
+
117
+ classes = _list_classes_by_teacher(teacher_id)
118
+ if not classes:
119
+ st.info("No classes yet. Create one in Classroom Management.")
120
+ return
121
+
122
+ # class selector
123
+ idx = st.selectbox(
124
+ "Choose a class",
125
+ list(range(len(classes))),
126
+ index=0,
127
+ format_func=lambda i: f"{classes[i].get('name','(unnamed)')}"
128
+ )
129
+ selected = classes[idx]
130
+ class_id = selected.get("class_id") or selected.get("id") # be tolerant to backend naming
131
+ if class_id is None:
132
+ st.error("Selected class is missing an ID.")
133
+ return
134
+
135
+ code_row = _get_class(class_id)
136
+
137
+ # get students before drawing chips
138
+ rows = _class_student_metrics(class_id)
139
+
140
+ # code + student chip row
141
+ chip1, chip2 = st.columns([1, 1])
142
+ with chip1:
143
+ st.markdown(
144
+ f'<div class="sm-chip">Code: {code_row.get("code","")}</div>',
145
+ unsafe_allow_html=True
146
+ )
147
+ with chip2:
148
+ st.markdown(
149
+ f'<div class="sm-chip">👥 {len(rows)} Students</div>',
150
+ unsafe_allow_html=True
151
+ )
152
+
153
+ st.markdown("---")
154
+
155
+ # search line
156
+ query = st.text_input(
157
+ "Search students by name or email",
158
+ placeholder="Type a name or email..."
159
+ ).strip().lower()
160
+
161
+ if query:
162
+ rows = [
163
+ r for r in rows
164
+ if query in (r.get("name","").lower()) or query in (r.get("email","").lower())
165
+ ]
166
+
167
+ # student rows
168
+ for r in rows:
169
+ name = r.get("name", "Unknown")
170
+ email = r.get("email", "")
171
+ joined = str(r.get("joined_at", ""))[:10]
172
+ total_xp = int(r.get("total_xp") or 0)
173
+ level = _level_from_xp(total_xp)
174
+ lessons_completed = int(r.get("lessons_completed") or 0)
175
+ total_assigned = int(r.get("total_assigned_lessons") or 0)
176
+ avg_pct = _avg_pct_from_row(r)
177
+ streak = int(r.get("streak_days") or 0)
178
+ student_id = r.get("student_id") or r.get("id")
179
+
180
+ with st.container():
181
+ st.markdown('<div class="sm-row">', unsafe_allow_html=True)
182
+
183
+ # top bar: avatar + name/email + right metrics
184
+ a, b, c = st.columns([0.7, 4, 3])
185
+ with a:
186
+ st.markdown(f"### {_avatar(name)}")
187
+ with b:
188
+ st.markdown(f'<div class="sm-name">{name}</div>', unsafe_allow_html=True)
189
+ st.markdown(f'<div class="sm-sub">{email} · Joined {joined}</div>', unsafe_allow_html=True)
190
+ with c:
191
+ st.markdown(
192
+ '<div class="sm-right">'
193
+ f'<div class="sm-metric"><div class="value">{level}</div><div class="label">Level</div></div>'
194
+ f'<div class="sm-metric"><div class="value">{avg_pct}%</div><div class="label">Avg Score</div></div>'
195
+ f'<div class="sm-metric"><div class="value">{streak}</div><div class="label">Streak</div></div>'
196
+ "</div>",
197
+ unsafe_allow_html=True
198
+ )
199
+
200
+ # progress bar
201
+ st.caption("Overall Progress")
202
+ frac = (lessons_completed / total_assigned) if total_assigned > 0 else 0.0
203
+ st.progress(min(1.0, frac))
204
+ st.caption(f"{lessons_completed}/{total_assigned} lessons")
205
+
206
+ # actions row
207
+ d1, d2, spacer = st.columns([2, 1.3, 5])
208
+ with d1:
209
+ with st.popover("👁️ View Details"):
210
+ if student_id is not None:
211
+ items = _list_assignments_for_student(int(student_id))
212
+ else:
213
+ items = []
214
+ if items:
215
+ for it in items[:25]:
216
+ tag = " + Quiz" if it.get("quiz_id") else ""
217
+ st.markdown(
218
+ f"- **{it.get('title','Untitled')}** · {it.get('subject','General')} · "
219
+ f"{it.get('level','')} {tag} · Status: {it.get('status','unknown')}"
220
+ )
221
+ else:
222
+ st.info("No assignments yet.")
223
+ with d2:
224
+ rep = _report_text(r, level, avg_pct)
225
+ st.download_button(
226
+ "⬇️ Export",
227
+ data=rep,
228
+ file_name=f"{str(name).replace(' ','_')}_report.txt",
229
+ mime="text/plain",
230
+ key=f"dl_{student_id or name}"
231
+ )
232
+
233
+ st.markdown('</div>', unsafe_allow_html=True)
requirements.txt ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- Core UI ----
2
+ streamlit
3
+
4
+ # ---- Agent Orchestration / LLM Stack ----
5
+ langgraph
6
+ langchain
7
+ langchain-community
8
+ langchain-openai
9
+ openai
10
+
11
+ # ---- Database (TiDB via MySQL protocol) ----
12
+ SQLAlchemy
13
+ PyMySQL
14
+ cryptography
15
+ certifi # SSL CA bundle for TiDB
16
+
17
+ # ---- Serialization / Hashing (LangGraph deps) ----
18
+ ormsgpack
19
+ xxhash
20
+
21
+ # ---- Pydantic v2 line (required by langchain-openai/openai) ----
22
+ pydantic
23
+ pydantic-core
24
+
25
+ # ---- Utilities ----
26
+ tenacity
27
+ requests
28
+ markdownify
29
+
30
+ # ---- Search (optional tool for your agent) ----
31
+ duckduckgo-search
32
+ bcrypt
33
+ plotly
34
+
35
+
36
+ python-dotenv
37
+ requests
38
+ huggingface_hub==0.34.4
39
+
40
+ # frontend requirements.txt (add this line)
41
+ mysql-connector-python
tools/__init__.py ADDED
File without changes
utils/api.py ADDED
@@ -0,0 +1,660 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # utils/api.py
2
+ import os, json, requests
3
+ from urllib3.util.retry import Retry
4
+ from requests.adapters import HTTPAdapter
5
+
6
+
7
+ # ---- Setup ----
8
+ BACKEND = (os.getenv("BACKEND_URL") or "").strip().rstrip("/")
9
+ if not BACKEND:
10
+ # Fail fast at import; Streamlit will surface this in the sidebar on first run
11
+ raise RuntimeError("BACKEND_URL is not set in Space secrets.")
12
+
13
+ # Accept either BACKEND_TOKEN or HF_TOKEN
14
+ TOKEN = (os.getenv("BACKEND_TOKEN") or os.getenv("HF_TOKEN") or "").strip()
15
+
16
+ DEFAULT_TIMEOUT = int(os.getenv("BACKEND_TIMEOUT", "30"))
17
+
18
+ _session = requests.Session()
19
+
20
+ # Light retry for transient network/server blips
21
+ retry = Retry(
22
+ total=3,
23
+ connect=3,
24
+ read=3,
25
+ backoff_factor=0.5,
26
+ status_forcelist=(429, 500, 502, 503, 504),
27
+ allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE"]),
28
+ )
29
+ _session.mount("https://", HTTPAdapter(max_retries=retry))
30
+ _session.mount("http://", HTTPAdapter(max_retries=retry))
31
+
32
+ # Default headers
33
+ _session.headers.update({
34
+ "Accept": "application/json, */*;q=0.1",
35
+ "User-Agent": "FinEdu-Frontend/1.0 (+spaces)",
36
+ })
37
+ if TOKEN:
38
+ _session.headers["Authorization"] = f"Bearer {TOKEN}"
39
+
40
+ def _json_or_raise(resp: requests.Response):
41
+ ctype = resp.headers.get("content-type", "")
42
+ if "application/json" in ctype:
43
+ return resp.json()
44
+ # Try to parse anyway; show a helpful error if not JSON
45
+ try:
46
+ return resp.json()
47
+ except Exception:
48
+ snippet = (resp.text or "")[:300]
49
+ raise RuntimeError(f"Expected JSON but got {ctype or 'unknown'}:\n{snippet}")
50
+
51
+ def _req(method: str, path: str, **kw):
52
+ if not path.startswith("/"):
53
+ path = "/" + path
54
+ url = f"{BACKEND}{path}"
55
+ kw.setdefault("timeout", DEFAULT_TIMEOUT)
56
+ try:
57
+ r = _session.request(method, url, **kw)
58
+ r.raise_for_status()
59
+ except requests.HTTPError as e:
60
+ body = ""
61
+ try:
62
+ body = r.text[:500]
63
+ except Exception:
64
+ pass
65
+ status = getattr(r, "status_code", "?")
66
+ # Give nicer hints for common auth misconfigs
67
+ if status in (401, 403):
68
+ raise RuntimeError(
69
+ f"{method} {path} failed [{status}] – auth rejected. "
70
+ f"Check BACKEND_TOKEN/HF_TOKEN permissions and that the backend Space is private/readable."
71
+ ) from e
72
+ raise RuntimeError(f"{method} {path} failed [{status}]: {body}") from e
73
+ except requests.RequestException as e:
74
+ raise RuntimeError(f"{method} {path} failed: {e.__class__.__name__}: {e}") from e
75
+ return r
76
+
77
+ # ---- Health ----
78
+ def health():
79
+ # Prefer /health but allow root fallback if you change the backend later
80
+ try:
81
+ return _json_or_raise(_req("GET", "/health"))
82
+ except Exception:
83
+ # best-effort fallback
84
+ try:
85
+ _req("GET", "/")
86
+ return {"ok": True}
87
+ except Exception:
88
+ return {"ok": False}
89
+
90
+ #---helpers
91
+
92
+ # --- Optional API prefix (e.g., "/api" or "/v1")
93
+ API_PREFIX_ENV = (os.getenv("BACKEND_API_PREFIX") or "").strip().rstrip("/")
94
+
95
+ def _prefixes():
96
+ # Try configured prefix first, then common fallbacks
97
+ seen, out = set(), []
98
+ for p in [API_PREFIX_ENV, "", "/api", "/v1", "/api/v1"]:
99
+ p = (p or "").strip()
100
+ p = "" if p == "" else ("/" + p.strip("/"))
101
+ if p not in seen:
102
+ out.append(p)
103
+ seen.add(p)
104
+ return out
105
+
106
+ def _try_candidates(method: str, candidates: list[tuple[str, dict]]):
107
+ """
108
+ candidates: list of (path, request_kwargs) where path starts with "/" and
109
+ kwargs may include {'params':..., 'json':...}.
110
+ Tries multiple prefixes (e.g., "", "/api", "/v1") and returns JSON for first 2xx.
111
+ Auth errors (401/403) are raised immediately.
112
+ """
113
+ tried = []
114
+ for pref in _prefixes():
115
+ for path, kw in candidates:
116
+ url = f"{BACKEND}{pref}{path}"
117
+ tried.append(f"{method} {url}")
118
+ try:
119
+ r = _session.request(method, url, timeout=DEFAULT_TIMEOUT, **kw)
120
+ except requests.RequestException as e:
121
+ # transient error: keep trying others
122
+ continue
123
+ if r.status_code in (401, 403):
124
+ snippet = (r.text or "")[:200]
125
+ raise RuntimeError(f"{method} {path} auth failed [{r.status_code}]: {snippet}")
126
+ if 200 <= r.status_code < 300:
127
+ return _json_or_raise(r)
128
+ # 404/405/etc.: try next candidate
129
+ raise RuntimeError("No matching endpoint for this operation. Tried:\n- " + "\n- ".join(tried))
130
+
131
+
132
+ #--helpers for student_db.py
133
+ def user_stats(student_id: int):
134
+ return _req("GET", f"/students/{student_id}/stats").json()
135
+ def list_assignments_for_student(student_id: int):
136
+ return _req("GET", f"/students/{student_id}/assignments").json()
137
+ def student_quiz_average(student_id: int):
138
+ d = _req("GET", f"/students/{student_id}/quiz_avg").json()
139
+ # Normalize common shapes: {"avg": 82}, {"score_pct": "82"}, "82", 82
140
+ if isinstance(d, dict):
141
+ for k in ("avg", "average", "score_pct", "score", "value"):
142
+ if k in d:
143
+ v = d[k]
144
+ break
145
+ else:
146
+ # fallback: first numeric-ish value
147
+ v = next((vv for vv in d.values() if isinstance(vv, (int, float, str))), 0)
148
+ else:
149
+ v = d
150
+ try:
151
+ # handle strings like "82" or "82%"
152
+ return int(round(float(str(v).strip().rstrip("%"))))
153
+ except Exception:
154
+ return 0
155
+ def recent_lessons_for_student(student_id: int, limit: int = 5):
156
+ return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json()
157
+
158
+ # # --- Teacher endpoints (backend Space) ---
159
+ # def create_class(teacher_id: int, name: str):
160
+ # return _json_or_raise(_req("POST", f"/teachers/{teacher_id}/classes",
161
+ # json={"name": name}))
162
+
163
+ # def teacher_tiles(teacher_id: int):
164
+ # return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
165
+
166
+ # def list_classes_by_teacher(teacher_id: int):
167
+ # return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/classes"))
168
+
169
+ # def class_student_metrics(class_id: int):
170
+ # return _json_or_raise(_req("GET", f"/classes/{class_id}/student_metrics"))
171
+
172
+ # def class_weekly_activity(class_id: int):
173
+ # return _json_or_raise(_req("GET", f"/classes/{class_id}/weekly_activity"))
174
+
175
+ # def class_progress_overview(class_id: int):
176
+ # return _json_or_raise(_req("GET", f"/classes/{class_id}/progress_overview"))
177
+
178
+ # def class_recent_activity(class_id: int, limit=6, days=30):
179
+ # return _json_or_raise(_req("GET", f"/classes/{class_id}/recent_activity",
180
+ # params={"limit": limit, "days": days}))
181
+
182
+ # def list_students_in_class(class_id: int):
183
+ # return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
184
+
185
+ # Optional if you want to compute levels server-side
186
+ def level_from_xp(xp: int):
187
+ return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"]
188
+
189
+ #--teacherlink.py helpers
190
+ def join_class_by_code(student_id: int, code: str):
191
+ d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
192
+ # backend may return {"class_id": ...} or full class object; both are fine
193
+ return d.get("class_id", d)
194
+
195
+ def list_classes_for_student(student_id: int):
196
+ return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
197
+
198
+ def class_content_counts(class_id: int):
199
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/counts"))
200
+
201
+ def student_class_progress(student_id: int, class_id: int):
202
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
203
+
204
+ def leave_class(student_id: int, class_id: int):
205
+ # could also be DELETE /classes/{class_id}/students/{student_id}
206
+ _json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
207
+ return True
208
+
209
+ def student_assignments_for_class(student_id: int, class_id: int):
210
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
211
+
212
+
213
+
214
+
215
+ # ---------- TEACHERS / CLASSES / CONTENT (BACKEND ROUTES THAT EXIST) ----------
216
+
217
+ # Classes
218
+ def create_class(teacher_id: int, name: str):
219
+ # Backend has POST /teachers/{teacher_id}/classes with body {name}
220
+ return _try_candidates("POST", [
221
+ (f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
222
+ # fallbacks if you ever rename:
223
+ ("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
224
+ ])
225
+
226
+ def list_classes_by_teacher(teacher_id: int):
227
+ return _try_candidates("GET", [
228
+ (f"/teachers/{teacher_id}/classes", {}),
229
+ ])
230
+
231
+ def list_students_in_class(class_id: int):
232
+ # exact route in backend
233
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
234
+
235
+ def class_content_counts(class_id: int):
236
+ return _try_candidates("GET", [
237
+ (f"/classes/{class_id}/content_counts", {}),
238
+ (f"/classes/{class_id}/counts", {}),
239
+ ])
240
+
241
+ def list_class_assignments(class_id: int):
242
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/assignments"))
243
+
244
+ def class_analytics(class_id: int):
245
+ return _json_or_raise(_req("GET", f"/classes/{class_id}/analytics"))
246
+
247
+ def teacher_tiles(teacher_id: int):
248
+ return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
249
+
250
+ def class_student_metrics(class_id: int):
251
+ # backend: /classes/{id}/students/metrics
252
+ return _try_candidates("GET", [
253
+ (f"/classes/{class_id}/students/metrics", {}),
254
+ # tolerant fallbacks:
255
+ (f"/classes/{class_id}/student_metrics", {}),
256
+ (f"/classes/{class_id}/students", {}), # older shape (list of students)
257
+ ])
258
+
259
+ def class_weekly_activity(class_id: int):
260
+ # backend: /classes/{id}/activity/weekly
261
+ return _try_candidates("GET", [
262
+ (f"/classes/{class_id}/activity/weekly", {}),
263
+ (f"/classes/{class_id}/weekly_activity", {}),
264
+ ])
265
+
266
+ def class_progress_overview(class_id: int):
267
+ # backend: /classes/{id}/progress
268
+ return _try_candidates("GET", [
269
+ (f"/classes/{class_id}/progress", {}),
270
+ (f"/classes/{class_id}/progress_overview", {}),
271
+ ])
272
+
273
+ def class_recent_activity(class_id: int, limit=6, days=30):
274
+ # backend: /classes/{id}/activity/recent
275
+ return _try_candidates("GET", [
276
+ (f"/classes/{class_id}/activity/recent", {"params": {"limit": limit, "days": days}}),
277
+ (f"/classes/{class_id}/recent_activity", {"params": {"limit": limit, "days": days}}),
278
+ ])
279
+
280
+ # Lessons
281
+ def list_lessons_by_teacher(teacher_id: int):
282
+ return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/lessons"))
283
+
284
+ def create_lesson(teacher_id: int, title: str, description: str,
285
+ subject: str, level: str, sections: list[dict]):
286
+ payload = {
287
+ "title": title,
288
+ "description": description,
289
+ "subject": subject,
290
+ "level": level,
291
+ "sections": sections,
292
+ }
293
+ # backend route:
294
+ d = _try_candidates("POST", [
295
+ (f"/teachers/{teacher_id}/lessons", {"json": payload}),
296
+ # fallback if you later add a flat /lessons route:
297
+ ("/lessons", {"json": {"teacher_id": teacher_id, **payload}}),
298
+ ])
299
+ # tolerate both {"lesson_id": N} or full object with id
300
+ return d.get("lesson_id", d.get("id", d))
301
+
302
+ def get_lesson(lesson_id: int):
303
+ return _json_or_raise(_req("GET", f"/lessons/{lesson_id}"))
304
+
305
+ def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str,
306
+ subject: str, level: str, sections: list[dict]):
307
+ d = _req("PUT", f"/lessons/{lesson_id}", json={
308
+ "teacher_id": teacher_id,
309
+ "title": title,
310
+ "description": description,
311
+ "subject": subject,
312
+ "level": level,
313
+ "sections": sections
314
+ }).json()
315
+ return bool(d.get("ok", True))
316
+
317
+ def delete_lesson(lesson_id: int, teacher_id: int):
318
+ d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
319
+ return bool(d.get("ok", True)), d.get("message", "")
320
+
321
+ # Quizzes
322
+ def list_quizzes_by_teacher(teacher_id: int):
323
+ return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/quizzes"))
324
+
325
+ def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
326
+ d = _req("POST", "/quizzes", json={
327
+ "lesson_id": lesson_id, "title": title, "items": items, "settings": settings
328
+ }).json()
329
+ return d.get("quiz_id", d.get("id", d))
330
+
331
+ # def get_quiz(quiz_id: int):
332
+ # return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
333
+
334
+ # def get_quiz(quiz_id: int):
335
+ # # NEW wrapper that hits GET /quizzes/{quiz_id}
336
+ # return _req("GET", f"/quizzes/{quiz_id}")
337
+
338
+ def get_quiz(quiz_id: int):
339
+ """Fetch a teacher-created quiz (GET /quizzes/{quiz_id}) and return JSON."""
340
+ return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}"))
341
+
342
+ def submit_quiz(student_id, assignment_id, quiz_id, score, total, details):
343
+ payload = {"student_id": student_id, "assignment_id": assignment_id,
344
+ "quiz_id": quiz_id, "score": score, "total": total, "details": details}
345
+ return _req("POST", "/quizzes/submit", json=payload)
346
+
347
+ def update_quiz(quiz_id: int,
348
+ teacher_id: int, title: str, items: list[dict], settings: dict):
349
+ d = _req("PUT", f"/quizzes/{quiz_id}", json={
350
+ "teacher_id": teacher_id, "title": title, "items": items, "settings": settings
351
+ }).json()
352
+ return bool(d.get("ok", True))
353
+
354
+ def delete_quiz(quiz_id: int, teacher_id: int):
355
+ d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
356
+ return bool(d.get("ok", True)), d.get("message", "")
357
+
358
+ def list_assigned_students_for_lesson(lesson_id: int):
359
+ return _json_or_raise(_req("GET", f"/lessons/{lesson_id}/assignees"))
360
+
361
+ def list_assigned_students_for_quiz(quiz_id: int):
362
+ return _json_or_raise(_req("GET", f"/quizzes/{quiz_id}/assignees"))
363
+
364
+ # Assignments
365
+ def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int, due_at: str | None = None):
366
+ # backend route name is /assign (not /assignments)
367
+ d = _try_candidates("POST", [
368
+ ("/assign", {"json": {
369
+ "lesson_id": lesson_id, "quiz_id": quiz_id,
370
+ "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
371
+ }}),
372
+ ("/assignments", {"json": {
373
+ "lesson_id": lesson_id, "quiz_id": quiz_id,
374
+ "class_id": class_id, "teacher_id": teacher_id, "due_at": due_at
375
+ }}),
376
+ ])
377
+ return bool(d.get("ok", True))
378
+
379
+
380
+
381
+
382
+ # # ---- Classes / Teacher endpoints (tolerant) ----
383
+ # def create_class(teacher_id: int, name: str):
384
+ # return _try_candidates("POST", [
385
+ # (f"/teachers/{teacher_id}/classes", {"json": {"name": name}}),
386
+ # (f"/teachers/{teacher_id}/classrooms",{"json": {"name": name}}),
387
+ # ("/classes", {"json": {"teacher_id": teacher_id, "name": name}}),
388
+ # ("/classrooms", {"json": {"teacher_id": teacher_id, "name": name}}),
389
+ # ])
390
+
391
+ # def list_classes_by_teacher(teacher_id: int):
392
+ # return _try_candidates("GET", [
393
+ # (f"/teachers/{teacher_id}/classes", {}),
394
+ # (f"/teachers/{teacher_id}/classrooms", {}),
395
+ # (f"/classes/by-teacher/{teacher_id}", {}),
396
+ # (f"/classrooms/by-teacher/{teacher_id}", {}),
397
+ # ("/classes", {"params": {"teacher_id": teacher_id}}),
398
+ # ("/classrooms", {"params": {"teacher_id": teacher_id}}),
399
+ # ])
400
+
401
+ # def list_students_in_class(class_id: int):
402
+ # return _try_candidates("GET", [
403
+ # (f"/classes/{class_id}/students", {}),
404
+ # (f"/classrooms/{class_id}/students", {}),
405
+ # ("/students", {"params": {"class_id": class_id}}),
406
+ # ])
407
+
408
+ # def class_content_counts(class_id: int):
409
+ # return _try_candidates("GET", [
410
+ # (f"/classes/{class_id}/content_counts", {}),
411
+ # (f"/classrooms/{class_id}/content_counts", {}),
412
+ # (f"/classes/{class_id}/counts", {}),
413
+ # (f"/classrooms/{class_id}/counts", {}),
414
+ # ])
415
+
416
+ # def list_class_assignments(class_id: int):
417
+ # return _try_candidates("GET", [
418
+ # (f"/classes/{class_id}/assignments", {}),
419
+ # (f"/classrooms/{class_id}/assignments", {}),
420
+ # ("/assignments", {"params": {"class_id": class_id}}),
421
+ # ])
422
+
423
+ # def class_analytics(class_id: int):
424
+ # return _try_candidates("GET", [
425
+ # (f"/classes/{class_id}/analytics", {}),
426
+ # (f"/classrooms/{class_id}/analytics", {}),
427
+ # ])
428
+
429
+
430
+ # #--contentmanage.py helpers
431
+
432
+ # # ---------- Teacher/content management endpoints (backend Space) ----------
433
+ # def list_classes_by_teacher(teacher_id: int):
434
+ # return _req("GET", f"/teachers/{teacher_id}/classes").json()
435
+
436
+ # def list_all_students_for_teacher(teacher_id: int):
437
+ # return _req("GET", f"/teachers/{teacher_id}/students").json()
438
+
439
+ # def list_lessons_by_teacher(teacher_id: int):
440
+ # return _req("GET", f"/teachers/{teacher_id}/lessons").json()
441
+
442
+ # def list_quizzes_by_teacher(teacher_id: int):
443
+ # return _req("GET", f"/teachers/{teacher_id}/quizzes").json()
444
+
445
+ # def create_lesson(teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
446
+ # d = _req("POST", "/lessons", json={
447
+ # "teacher_id": teacher_id, "title": title, "description": description,
448
+ # "subject": subject, "level": level, "sections": sections
449
+ # }).json()
450
+ # return d["lesson_id"]
451
+
452
+ # def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
453
+ # d = _req("PUT", f"/lessons/{lesson_id}", json={
454
+ # "teacher_id": teacher_id, "title": title, "description": description,
455
+ # "subject": subject, "level": level, "sections": sections
456
+ # }).json()
457
+ # return bool(d.get("ok", True))
458
+
459
+ # def delete_lesson(lesson_id: int, teacher_id: int):
460
+ # d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
461
+ # return bool(d.get("ok", True)), d.get("message", "")
462
+
463
+ # def get_lesson(lesson_id: int):
464
+ # return _req("GET", f"/lessons/{lesson_id}").json() # {"lesson":{...}, "sections":[...]}
465
+
466
+ # def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
467
+ # d = _req("POST", "/quizzes", json={"lesson_id": lesson_id, "title": title, "items": items, "settings": settings}).json()
468
+ # return d["quiz_id"]
469
+
470
+ # def update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict):
471
+ # d = _req("PUT", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id, "title": title, "items": items, "settings": settings}).json()
472
+ # return bool(d.get("ok", True))
473
+
474
+ # def delete_quiz(quiz_id: int, teacher_id: int):
475
+ # d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
476
+ # return bool(d.get("ok", True)), d.get("message", "")
477
+
478
+ # def list_assigned_students_for_lesson(lesson_id: int):
479
+ # return _req("GET", f"/lessons/{lesson_id}/assignees").json()
480
+
481
+ # def list_assigned_students_for_quiz(quiz_id: int):
482
+ # return _req("GET", f"/quizzes/{quiz_id}/assignees").json()
483
+
484
+ # def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int):
485
+ # d = _req("POST", "/assignments", json={
486
+ # "lesson_id": lesson_id, "quiz_id": quiz_id, "class_id": class_id, "teacher_id": teacher_id
487
+ # }).json()
488
+ # return bool(d.get("ok", True))
489
+
490
+ # #-- studentlist helpers
491
+
492
+ # def list_classes_by_teacher(teacher_id: int):
493
+ # return _req("GET", f"/teachers/{teacher_id}/classes").json()
494
+
495
+ # def get_class(class_id: int):
496
+ # return _req("GET", f"/classes/{class_id}").json()
497
+
498
+ # def class_student_metrics(class_id: int):
499
+ # # expected to return list of rows with fields used in the UI
500
+ # return _req("GET", f"/classes/{class_id}/students").json()
501
+
502
+ # def list_assignments_for_student(student_id: int):
503
+ # return _req("GET", f"/students/{student_id}/assignments").json()
504
+
505
+ # ---------- LLM-based quiz generation (backend uses GEN_MODEL) ----------
506
+ def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
507
+ """
508
+ Backend should read GEN_MODEL from env and use your chosen model (llama-3.1-8b-instruct).
509
+ Return shape: {"items":[{"question":...,"options":[...],"answer_key":"A"}]}
510
+ """
511
+ return _req("POST", "/quiz/generate", json={
512
+ "content": content, "n_questions": n_questions, "subject": subject, "level": level
513
+ }).json()
514
+
515
+
516
+ # ---- Legacy agent endpoints (keep) ----
517
+ def start_agent(student_id: int, lesson_id: int, level_slug: str):
518
+ return _json_or_raise(_req("POST", "/agent/start",
519
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
520
+
521
+ def get_agent_quiz(student_id: int, lesson_id: int, level_slug: str):
522
+ d = _json_or_raise(_req("POST", "/agent/quiz",
523
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug}))
524
+ return d["items"]
525
+
526
+ def grade_quiz(student_id: int, lesson_id: int, level_slug: str,
527
+ answers: list[str], assignment_id: int | None = None):
528
+ d = _json_or_raise(_req("POST", "/agent/grade",
529
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
530
+ "answers": answers, "assignment_id": assignment_id}))
531
+ return d["score"], d["total"]
532
+
533
+ def next_step(student_id: int, lesson_id: int, level_slug: str,
534
+ answers: list[str], assignment_id: int | None = None):
535
+ return _json_or_raise(_req("POST", "/agent/coach_or_celebrate",
536
+ json={"student_id": student_id, "lesson_id": lesson_id, "level_slug": level_slug,
537
+ "answers": answers, "assignment_id": assignment_id}))
538
+
539
+ # ---- Auth ----
540
+ def login(email: str, password: str):
541
+ return _json_or_raise(_req("POST", "/auth/login", json={"email": email, "password": password}))
542
+
543
+ def signup_student(name: str, email: str, password: str, level_label: str, country_label: str):
544
+ payload_student = {
545
+ "name": name, "email": email, "password": password,
546
+ "level_label": level_label, "country_label": country_label
547
+ }
548
+ # Prefer dedicated route; fall back to /auth/register with role
549
+ return _try_candidates("POST", [
550
+ ("/auth/signup/student", {"json": payload_student}),
551
+ ("/auth/register", {"json": {
552
+ "role": "student", "name": name, "email": email, "password": password,
553
+ "level": level_label, "country": country_label
554
+ }}),
555
+ ])
556
+
557
+ def signup_teacher(title: str, name: str, email: str, password: str):
558
+ payload_teacher = {"title": title, "name": name, "email": email, "password": password}
559
+ return _try_candidates("POST", [
560
+ ("/auth/signup/teacher", {"json": payload_teacher}),
561
+ ("/auth/register", {"json": {
562
+ "role": "teacher", "title": title, "name": name, "email": email, "password": password
563
+ }}),
564
+ ])
565
+
566
+ # ---- New LangGraph-backed endpoints ----
567
+ def fetch_lesson_content(lesson: str, module: str, topic: str):
568
+ r = _json_or_raise(_req("POST", "/lesson",
569
+ json={"lesson": lesson, "module": module, "topic": topic}))
570
+ return r["lesson_content"]
571
+
572
+ def submit_lesson_quiz(lesson: str, module: str, topic: str, responses: dict):
573
+ return _json_or_raise(_req("POST", "/lesson-quiz",
574
+ json={"lesson": lesson, "module": module, "topic": topic, "responses": responses}))
575
+
576
+ def submit_practice_quiz(lesson: str, responses: dict):
577
+ return _json_or_raise(_req("POST", "/practice-quiz",
578
+ json={"lesson": lesson, "responses": responses}))
579
+
580
+ def send_to_chatbot(messages: list[dict]):
581
+ return _json_or_raise(_req("POST", "/chatbot", json={"messages": messages}))
582
+
583
+
584
+ # --- Game API helpers ---
585
+
586
+ def record_money_match_play(user_id: int, target: int, total: int,
587
+ elapsed_ms: int, matched: bool, gained_xp: int):
588
+ payload = {
589
+ "user_id": user_id, "target": target, "total": total,
590
+ "elapsed_ms": elapsed_ms, "matched": matched, "gained_xp": gained_xp,
591
+ }
592
+ return _try_candidates("POST", [
593
+ ("/games/money_match/record", {"json": payload}),
594
+ ])
595
+
596
+ def record_budget_builder_play(user_id: int, weekly_allowance: int, budget_score: int,
597
+ elapsed_ms: int, allocations: list[dict], gained_xp: int | None):
598
+ payload = {
599
+ "user_id": user_id,
600
+ "weekly_allowance": weekly_allowance,
601
+ "budget_score": budget_score,
602
+ "elapsed_ms": elapsed_ms,
603
+ "allocations": allocations,
604
+ "gained_xp": gained_xp,
605
+ }
606
+ return _try_candidates("POST", [
607
+ ("/games/budget_builder/record", {"json": payload}),
608
+ ])
609
+
610
+
611
+ def record_debt_dilemma_play(user_id: int, loans_cleared: int,
612
+ mistakes: int, elapsed_ms: int, gained_xp: int):
613
+ payload = {
614
+ "user_id": user_id,
615
+ "loans_cleared": loans_cleared,
616
+ "mistakes": mistakes,
617
+ "elapsed_ms": elapsed_ms,
618
+ "gained_xp": gained_xp,
619
+ }
620
+ return _try_candidates("POST", [
621
+ ("/games/debt_dilemma/record", {"json": payload}),
622
+ ("/api/games/debt_dilemma/record", {"json": payload}),
623
+ ("/api/v1/games/debt_dilemma/record", {"json": payload}),
624
+ ])
625
+
626
+
627
+ def record_profit_puzzler_play(user_id: int, puzzles_solved: int, mistakes: int, elapsed_ms: int, gained_xp: int | None = None):
628
+ payload = {"user_id": user_id, "puzzles_solved": puzzles_solved, "mistakes": mistakes, "elapsed_ms": elapsed_ms}
629
+ if gained_xp is not None:
630
+ payload["gained_xp"] = gained_xp
631
+ return _try_candidates("POST", [("/games/profit_puzzler/record", {"json": payload})])
632
+
633
+
634
+ def generate_quiz(lesson_id: int, level_slug: str, lesson_title: str):
635
+ r = requests.post(f"{BACKEND}/generate_quiz", json={
636
+ "lesson_id": lesson_id,
637
+ "level_slug": level_slug,
638
+ "lesson_title": lesson_title
639
+ }, timeout=60)
640
+ r.raise_for_status()
641
+ return r.json()["quiz"]
642
+
643
+ def submit_quiz(lesson_id: int, level_slug: str, user_answers: list[dict], original_quiz: list[dict]):
644
+ r = requests.post(f"{BACKEND}/submit_quiz", json={
645
+ "lesson_id": lesson_id,
646
+ "level_slug": level_slug,
647
+ "user_answers": user_answers,
648
+ "original_quiz": original_quiz
649
+ }, timeout=90)
650
+ r.raise_for_status()
651
+ return r.json()
652
+
653
+ def tutor_explain(lesson_id: int, level_slug: str, wrong: list[dict]):
654
+ r = requests.post(f"{BACKEND}/tutor/explain", json={
655
+ "lesson_id": lesson_id,
656
+ "level_slug": level_slug,
657
+ "wrong": wrong
658
+ }, timeout=60)
659
+ r.raise_for_status()
660
+ return r.json()["feedback"]
utils/db.py ADDED
@@ -0,0 +1,1327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # utils/db.py (top of file)
2
+ import os
3
+ import json
4
+ import certifi
5
+ from contextlib import contextmanager
6
+ from datetime import date, timedelta
7
+
8
+ # password hashing
9
+ import bcrypt
10
+
11
+ # --- Feature flag: DB off by default in frontend Space ---
12
+ DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1"
13
+
14
+ # Import mysql connector only when DB is enabled
15
+ MYSQL_AVAILABLE = False
16
+ if not DISABLE_DB:
17
+ try:
18
+ import mysql.connector # provided by mysql-connector-python
19
+ from mysql.connector import Error # noqa: F401
20
+ MYSQL_AVAILABLE = True
21
+ except Exception:
22
+ MYSQL_AVAILABLE = False # will raise a friendly error if used
23
+
24
+ def _db_disabled_error():
25
+ raise RuntimeError(
26
+ "Database access is disabled in this frontend (DISABLE_DB=1). "
27
+ "Route calls through your backend Space instead."
28
+ )
29
+
30
+ def get_db_connection():
31
+ if DISABLE_DB or not MYSQL_AVAILABLE:
32
+ _db_disabled_error()
33
+ ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
34
+ ssl_ca = certifi.where() if ssl_enabled else None
35
+ return mysql.connector.connect(
36
+ host=os.getenv("TIDB_HOST"),
37
+ port=int(os.getenv("TIDB_PORT", 4000)),
38
+ user=os.getenv("TIDB_USER"),
39
+ password=os.getenv("TIDB_PASSWORD"),
40
+ database=os.getenv("TIDB_DATABASE", "fin_ed_agentic"),
41
+ ssl_ca=ssl_ca,
42
+ ssl_verify_cert=ssl_enabled,
43
+ autocommit=True,
44
+ )
45
+
46
+ @contextmanager
47
+ def cursor(dict_rows=True):
48
+ if DISABLE_DB or not MYSQL_AVAILABLE:
49
+ _db_disabled_error()
50
+ conn = get_db_connection()
51
+ try:
52
+ cur = conn.cursor(dictionary=dict_rows)
53
+ yield cur
54
+ conn.commit()
55
+ finally:
56
+ cur.close()
57
+ conn.close()
58
+
59
+
60
+
61
+ # password hashing
62
+ import bcrypt
63
+
64
+ # ----------- label <-> slug mappers for UI selects -----------
65
+ COUNTRY_SLUG = {
66
+ "Jamaica": "jamaica", "USA": "usa", "UK": "uk",
67
+ "India": "india", "Canada": "canada", "Other": "other", "N/A": "na"
68
+ }
69
+ LEVEL_SLUG = {
70
+ "Beginner": "beginner", "Intermediate": "intermediate", "Advanced": "advanced", "N/A": "na"
71
+ }
72
+ ROLE_SLUG = {"Student": "student", "Teacher": "teacher"}
73
+
74
+ def _slug(s: str) -> str:
75
+ return (s or "").strip().lower()
76
+
77
+ def hash_password(plain: str) -> bytes:
78
+ return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt())
79
+
80
+ def verify_password(plain: str, hashed: bytes | None) -> bool:
81
+ if not plain or not hashed:
82
+ return False
83
+ try:
84
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed)
85
+ except Exception:
86
+ return False
87
+
88
+ def _ensure_na_slugs():
89
+ """
90
+ Make sure 'na' exists in countries/levels for teacher rows.
91
+ Harmless if already present.
92
+ """
93
+ with cursor() as cur:
94
+ cur.execute("INSERT IGNORE INTO countries(slug,label) VALUES('na','N/A')")
95
+ cur.execute("INSERT IGNORE INTO levels(slug,label) VALUES('na','N/A')")
96
+
97
+
98
+ # def get_db_connection():
99
+ # ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true"
100
+ # ssl_ca = certifi.where() if ssl_enabled else None
101
+ # return mysql.connector.connect(
102
+ # host=os.getenv("TIDB_HOST"),
103
+ # port=int(os.getenv("TIDB_PORT", 4000)),
104
+ # user=os.getenv("TIDB_USER"),
105
+ # password=os.getenv("TIDB_PASSWORD"),
106
+ # database=os.getenv("TIDB_DATABASE", "agenticfinance"),
107
+ # ssl_ca=ssl_ca,
108
+ # ssl_verify_cert=ssl_enabled,
109
+ # autocommit=True,
110
+ # )
111
+
112
+ @contextmanager
113
+ def cursor(dict_rows=True):
114
+ conn = get_db_connection()
115
+ try:
116
+ cur = conn.cursor(dictionary=dict_rows)
117
+ yield cur
118
+ conn.commit()
119
+ finally:
120
+ cur.close()
121
+ conn.close()
122
+
123
+ # ---------- USERS ----------
124
+ def create_user(name:str, email:str, country:str, level:str, role:str):
125
+
126
+ slug = lambda s: s.strip().lower()
127
+ with cursor() as cur:
128
+ cur.execute("""
129
+ INSERT INTO users(name,email,country_slug,level_slug,role_slug)
130
+ VALUES (%s,%s,%s,%s,%s)
131
+ """, (name, email.strip().lower(), slug(country), slug(level), slug(role)))
132
+ return True
133
+
134
+ # role-specific creators
135
+ def create_student(*, name:str, email:str, password:str, level_label:str, country_label:str) -> bool:
136
+ """
137
+ level_label/country_label are UI labels (e.g., 'Beginner', 'Jamaica').
138
+ """
139
+ level_slug = LEVEL_SLUG.get(level_label, _slug(level_label))
140
+ country_slug = COUNTRY_SLUG.get(country_label, _slug(country_label))
141
+ with cursor() as cur:
142
+ cur.execute("""
143
+ INSERT INTO users (name,email,password_hash,title,country_slug,level_slug,role_slug)
144
+ VALUES (%s,%s,%s,NULL,%s,%s,'student')
145
+ """, (name.strip(), email.strip().lower(), hash_password(password), country_slug, level_slug))
146
+ return True
147
+
148
+ def create_teacher(*, title:str, name:str, email:str, password:str) -> bool:
149
+ """
150
+ Teachers do not provide level/country; we store 'na' for both.
151
+ """
152
+ _ensure_na_slugs()
153
+ with cursor() as cur:
154
+ cur.execute("""
155
+ INSERT INTO users (title,name,email,password_hash,country_slug,level_slug,role_slug)
156
+ VALUES (%s,%s,%s,%s,'na','na','teacher')
157
+ """, (title.strip(), name.strip(), email.strip().lower(), hash_password(password)))
158
+ return True
159
+
160
+
161
+ def get_user_by_email(email:str):
162
+ with cursor() as cur:
163
+ cur.execute("""
164
+ SELECT
165
+ u.user_id, u.title, u.name, u.email, u.password_hash,
166
+ u.country_slug, c.label AS country,
167
+ u.level_slug, l.label AS level,
168
+ u.role_slug, r.label AS role
169
+ FROM users u
170
+ JOIN countries c ON c.slug = u.country_slug
171
+ JOIN levels l ON l.slug = u.level_slug
172
+ JOIN roles r ON r.slug = u.role_slug
173
+ WHERE u.email=%s
174
+ LIMIT 1
175
+ """, (email.strip().lower(),))
176
+ u = cur.fetchone()
177
+ if not u:
178
+ return None
179
+
180
+ u["role"] = "Teacher" if u["role_slug"] == "teacher" else "Student"
181
+ return u
182
+
183
+ def check_password(email: str, plain_password: str) -> dict | None:
184
+ """
185
+ Returns the user dict if password is correct, else None.
186
+ """
187
+ user = get_user_by_email(email)
188
+ if not user:
189
+ return None
190
+ if verify_password(plain_password, user.get("password_hash")):
191
+ return user
192
+ return None
193
+
194
+
195
+ # ---------- CLASSES ----------
196
+ import random, string
197
+ def _code():
198
+ return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
199
+
200
+ def create_class(teacher_id:int, name:str):
201
+ # ensure unique code
202
+ for _ in range(20):
203
+ code = _code()
204
+ with cursor() as cur:
205
+ cur.execute("SELECT 1 FROM classes WHERE code=%s", (code,))
206
+ if not cur.fetchone():
207
+ cur.execute("INSERT INTO classes(teacher_id,name,code) VALUES(%s,%s,%s)",
208
+ (teacher_id, name, code))
209
+ cur.execute("SELECT LAST_INSERT_ID() AS id")
210
+ cid = cur.fetchone()["id"]
211
+ return {"class_id": cid, "code": code}
212
+ raise RuntimeError("Could not generate unique class code")
213
+
214
+
215
+
216
+ def join_class_by_code(student_id:int, code:str):
217
+ with cursor() as cur:
218
+ cur.execute("SELECT class_id FROM classes WHERE code=%s", (code.strip().upper(),))
219
+ row = cur.fetchone()
220
+ if not row:
221
+ raise ValueError("Invalid class code")
222
+ cur.execute("INSERT IGNORE INTO class_students(class_id,student_id) VALUES(%s,%s)",
223
+ (row["class_id"], student_id))
224
+ return row["class_id"]
225
+
226
+ def list_students_in_class(class_id:int):
227
+ with cursor() as cur:
228
+ cur.execute("""
229
+ SELECT
230
+ u.user_id, u.name, u.email, u.level_slug,
231
+ cs.joined_at, -- <- show true join date
232
+ u.created_at
233
+ FROM class_students cs
234
+ JOIN users u ON u.user_id = cs.student_id
235
+ WHERE cs.class_id = %s
236
+ ORDER BY u.name
237
+ """, (class_id,))
238
+ return cur.fetchall()
239
+
240
+ def class_analytics(class_id:int):
241
+ """
242
+ Returns:
243
+ class_avg -> 0..1 average quiz score for the class (from v_class_stats)
244
+ total_xp -> sum of xp_log.delta for students in this class
245
+ lessons_completed -> count of completed lesson_progress entries for lessons assigned to this class
246
+ """
247
+ out = {"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0}
248
+
249
+ with cursor() as cur:
250
+ # class average from view
251
+ cur.execute("SELECT class_avg FROM v_class_stats WHERE class_id=%s", (class_id,))
252
+ row = cur.fetchone()
253
+ if row:
254
+ out["class_avg"] = float(row["class_avg"] or 0)
255
+
256
+ # total XP for all students in this class
257
+ cur.execute("""
258
+ SELECT COALESCE(SUM(x.delta),0) AS total_xp
259
+ FROM xp_log x
260
+ JOIN class_students cs ON cs.student_id = x.user_id
261
+ WHERE cs.class_id = %s
262
+ """, (class_id,))
263
+ out["total_xp"] = int((cur.fetchone() or {"total_xp": 0})["total_xp"])
264
+
265
+ # lessons completed that were actually assigned to this class
266
+ cur.execute("""
267
+ SELECT COUNT(*) AS n
268
+ FROM lesson_progress lp
269
+ JOIN class_students cs ON cs.student_id = lp.user_id
270
+ JOIN assignments a ON a.lesson_id = lp.lesson_id
271
+ WHERE cs.class_id = %s
272
+ AND a.class_id = %s
273
+ AND lp.status = 'completed'
274
+ """, (class_id, class_id))
275
+ out["lessons_completed"] = int((cur.fetchone() or {"n": 0})["n"])
276
+
277
+ return out
278
+
279
+ # ---------- Teacher dash for real time data - Class Helpers ----------
280
+ def class_content_counts(class_id:int):
281
+ # counts of distinct lessons and quizzes assigned to this class
282
+ with cursor() as cur:
283
+ cur.execute("""
284
+ SELECT
285
+ COUNT(DISTINCT lesson_id) AS lessons,
286
+ COUNT(DISTINCT quiz_id) AS quizzes
287
+ FROM assignments
288
+ WHERE class_id=%s
289
+ """, (class_id,))
290
+ row = cur.fetchone() or {"lessons": 0, "quizzes": 0}
291
+ return row
292
+
293
+ def list_class_assignments(class_id:int):
294
+ with cursor() as cur:
295
+ cur.execute("""
296
+ SELECT
297
+ a.assignment_id,
298
+ a.created_at,
299
+ l.lesson_id, l.title, l.subject, l.level,
300
+ a.quiz_id
301
+ FROM assignments a
302
+ JOIN lessons l ON l.lesson_id = a.lesson_id
303
+ WHERE a.class_id=%s
304
+ ORDER BY a.created_at DESC
305
+ """, (class_id,))
306
+ return cur.fetchall()
307
+
308
+ def list_classes_by_teacher(teacher_id:int):
309
+ with cursor() as cur:
310
+ cur.execute("""
311
+ SELECT s.*, c.code
312
+ FROM v_class_stats s
313
+ JOIN classes c USING (class_id)
314
+ WHERE s.teacher_id=%s
315
+ ORDER BY c.created_at DESC
316
+ """, (teacher_id,))
317
+ return cur.fetchall()
318
+
319
+ def get_class(class_id:int):
320
+ with cursor() as cur:
321
+ cur.execute("SELECT class_id, name, code, teacher_id FROM classes WHERE class_id=%s", (class_id,))
322
+ return cur.fetchone()
323
+
324
+ def class_student_metrics(class_id: int):
325
+ """
326
+ Returns one row per student in the class with:
327
+ name, email, joined_at, lessons_completed, total_assigned_lessons,
328
+ avg_score (0..1), streak_days, total_xp
329
+ """
330
+ with cursor() as cur:
331
+ cur.execute("""
332
+ /* total assigned lessons for the class */
333
+ WITH assigned AS (
334
+ SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
335
+ )
336
+ SELECT
337
+ cs.student_id,
338
+ u.name,
339
+ u.email,
340
+ cs.joined_at,
341
+ /* lessons completed by this student that were assigned to this class */
342
+ COALESCE(
343
+ (SELECT COUNT(*) FROM lesson_progress lp
344
+ WHERE lp.user_id = cs.student_id
345
+ AND lp.status = 'completed'
346
+ AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
347
+ ), 0
348
+ ) AS lessons_completed,
349
+ /* total lessons assigned to this class */
350
+ (SELECT COUNT(*) FROM assigned) AS total_assigned_lessons,
351
+ /* average quiz score only for submissions tied to this class */
352
+ COALESCE(sc.avg_score, 0) AS avg_score,
353
+ /* streak days from streaks table */
354
+ COALESCE(str.days, 0) AS streak_days,
355
+ /* total XP across the app */
356
+ COALESCE(xp.total_xp, 0) AS total_xp
357
+ FROM class_students cs
358
+ JOIN users u ON u.user_id = cs.student_id
359
+ LEFT JOIN (
360
+ SELECT s.student_id, AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_score
361
+ FROM submissions s
362
+ JOIN assignments a ON a.assignment_id = s.assignment_id
363
+ WHERE a.class_id = %s
364
+ GROUP BY s.student_id
365
+ ) sc ON sc.student_id = cs.student_id
366
+ LEFT JOIN streaks str ON str.user_id = cs.student_id
367
+ LEFT JOIN (SELECT user_id, SUM(delta) AS total_xp FROM xp_log GROUP BY user_id) xp
368
+ ON xp.user_id = cs.student_id
369
+ WHERE cs.class_id = %s
370
+ ORDER BY u.name;
371
+ """, (class_id, class_id, class_id))
372
+ return cur.fetchall()
373
+
374
+ def level_from_xp(total_xp: int) -> int:
375
+ try:
376
+ xp = int(total_xp or 0)
377
+ except Exception:
378
+ xp = 0
379
+ return 1 + xp // 500
380
+
381
+
382
+ def list_classes_for_student(student_id: int):
383
+ with cursor() as cur:
384
+ cur.execute("""
385
+ SELECT c.class_id, c.name, c.code, c.teacher_id,
386
+ t.name AS teacher_name, cs.joined_at
387
+ FROM class_students cs
388
+ JOIN classes c ON c.class_id = cs.class_id
389
+ JOIN users t ON t.user_id = c.teacher_id
390
+ WHERE cs.student_id = %s
391
+ ORDER BY cs.joined_at DESC
392
+ """, (student_id,))
393
+ return cur.fetchall()
394
+
395
+ def leave_class(student_id: int, class_id: int):
396
+ with cursor() as cur:
397
+ cur.execute("DELETE FROM class_students WHERE student_id=%s AND class_id=%s",
398
+ (student_id, class_id))
399
+ return True
400
+
401
+ def student_class_progress(student_id: int, class_id: int):
402
+ """
403
+ Per-student view of progress inside ONE class.
404
+ Returns: dict(overall_progress 0..1, lessons_completed int,
405
+ total_assigned_lessons int, avg_score 0..1)
406
+ """
407
+ with cursor() as cur:
408
+ # total distinct lessons assigned to this class
409
+ cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s",
410
+ (class_id,))
411
+ total_assigned = int((cur.fetchone() or {"n": 0})["n"])
412
+
413
+ # lessons completed among the class's assigned lessons
414
+ cur.execute("""
415
+ WITH assigned AS (SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s)
416
+ SELECT COUNT(*) AS n
417
+ FROM lesson_progress lp
418
+ WHERE lp.user_id = %s
419
+ AND lp.status = 'completed'
420
+ AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
421
+ """, (class_id, student_id))
422
+ completed = int((cur.fetchone() or {"n": 0})["n"])
423
+
424
+ # student’s avg quiz score but only for submissions tied to this class
425
+ cur.execute("""
426
+ SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
427
+ FROM submissions s
428
+ JOIN assignments a ON a.assignment_id = s.assignment_id
429
+ WHERE a.class_id = %s AND s.student_id = %s
430
+ """, (class_id, student_id))
431
+ avg_score = float((cur.fetchone() or {"avg_ratio": 0.0})["avg_ratio"] or 0.0)
432
+
433
+ overall = (completed / float(total_assigned)) if total_assigned else 0.0
434
+ return dict(
435
+ overall_progress=overall,
436
+ lessons_completed=completed,
437
+ total_assigned_lessons=total_assigned,
438
+ avg_score=avg_score
439
+ )
440
+
441
+ def student_assignments_for_class(student_id: int, class_id: int):
442
+ """
443
+ All assignments in a class, annotated with THIS student's status/progress
444
+ and (if applicable) their quiz score for that assignment.
445
+ Deduplicates by lesson_id (keeps the most recent assignment per lesson).
446
+ """
447
+ with cursor() as cur:
448
+ cur.execute("""
449
+ SELECT
450
+ a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
451
+ a.quiz_id, a.due_at,
452
+ COALESCE(lp.status,'not_started') AS status,
453
+ lp.current_pos,
454
+ /* student's latest submission on this assignment (if any) */
455
+ (SELECT MAX(s.submitted_at) FROM submissions s
456
+ WHERE s.assignment_id = a.assignment_id AND s.student_id = %s) AS last_submit_at,
457
+ (SELECT s2.score FROM submissions s2
458
+ WHERE s2.assignment_id = a.assignment_id AND s2.student_id = %s
459
+ ORDER BY s2.submitted_at DESC LIMIT 1) AS score,
460
+ (SELECT s3.total FROM submissions s3
461
+ WHERE s3.assignment_id = a.assignment_id AND s3.student_id = %s
462
+ ORDER BY s3.submitted_at DESC LIMIT 1) AS total
463
+ FROM (
464
+ SELECT
465
+ a.*,
466
+ ROW_NUMBER() OVER (
467
+ PARTITION BY a.lesson_id
468
+ ORDER BY a.created_at DESC, a.assignment_id DESC
469
+ ) AS rn
470
+ FROM assignments a
471
+ WHERE a.class_id = %s
472
+ ) AS a
473
+ JOIN lessons l ON l.lesson_id = a.lesson_id
474
+ LEFT JOIN lesson_progress lp
475
+ ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
476
+ WHERE a.rn = 1
477
+ ORDER BY a.created_at DESC
478
+ """, (student_id, student_id, student_id, class_id, student_id))
479
+ return cur.fetchall()
480
+
481
+
482
+
483
+
484
+ def update_quiz(quiz_id:int, teacher_id:int, title:str, items:list[dict], settings:dict|None=None) -> bool:
485
+ with cursor() as cur:
486
+ # only the teacher who owns the linked lesson can edit
487
+ cur.execute("""
488
+ SELECT 1
489
+ FROM quizzes q
490
+ JOIN lessons l ON l.lesson_id = q.lesson_id
491
+ WHERE q.quiz_id = %s AND l.teacher_id = %s
492
+ LIMIT 1
493
+ """, (quiz_id, teacher_id))
494
+ if not cur.fetchone():
495
+ return False
496
+
497
+ cur.execute("UPDATE quizzes SET title=%s, settings=%s WHERE quiz_id=%s",
498
+ (title, json.dumps(settings or {}), quiz_id))
499
+
500
+ cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
501
+ for i, it in enumerate(items, start=1):
502
+ cur.execute("""
503
+ INSERT INTO quiz_items(quiz_id, position, question, options, answer_key, points)
504
+ VALUES (%s, %s, %s, %s, %s, %s)
505
+ """, (
506
+ quiz_id, i,
507
+ it["question"],
508
+ json.dumps(it.get("options", [])),
509
+ json.dumps(it.get("answer_key")), # single letter as JSON string
510
+ int(it.get("points", 1))
511
+ ))
512
+ return True
513
+
514
+
515
+ def class_weekly_activity(class_id:int):
516
+ start = date.today() - timedelta(days=6)
517
+ with cursor() as cur:
518
+ cur.execute("""
519
+ SELECT DATE(lp.last_accessed) d, COUNT(*) n
520
+ FROM lesson_progress lp
521
+ JOIN class_students cs ON cs.student_id = lp.user_id
522
+ WHERE cs.class_id=%s AND lp.last_accessed >= %s
523
+ GROUP BY DATE(lp.last_accessed)
524
+ """, (class_id, start))
525
+ lessons = {r["d"]: r["n"] for r in cur.fetchall()}
526
+
527
+ cur.execute("""
528
+ SELECT DATE(s.submitted_at) d, COUNT(*) n
529
+ FROM submissions s
530
+ JOIN assignments a ON a.assignment_id = s.assignment_id
531
+ WHERE a.class_id=%s AND s.submitted_at >= %s
532
+ GROUP BY DATE(s.submitted_at)
533
+ """, (class_id, start))
534
+ quizzes = {r["d"]: r["n"] for r in cur.fetchall()}
535
+
536
+ cur.execute("""
537
+ SELECT DATE(g.started_at) d, COUNT(*) n
538
+ FROM game_sessions g
539
+ JOIN class_students cs ON cs.student_id = g.user_id
540
+ WHERE cs.class_id=%s AND g.started_at >= %s
541
+ GROUP BY DATE(g.started_at)
542
+ """, (class_id, start))
543
+ games = {r["d"]: r["n"] for r in cur.fetchall()}
544
+
545
+ out = []
546
+ for i in range(7):
547
+ d = start + timedelta(days=i)
548
+ out.append({
549
+ "date": d,
550
+ "lessons": lessons.get(d, 0),
551
+ "quizzes": quizzes.get(d, 0),
552
+ "games": games.get(d, 0),
553
+ })
554
+ return out
555
+
556
+
557
+
558
+
559
+ def update_lesson(lesson_id:int, teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]) -> bool:
560
+ with cursor() as cur:
561
+ # ownership check
562
+ cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
563
+ if not cur.fetchone():
564
+ return False
565
+
566
+ cur.execute("""
567
+ UPDATE lessons
568
+ SET title=%s, description=%s, subject=%s, level=%s
569
+ WHERE lesson_id=%s AND teacher_id=%s
570
+ """, (title, description, subject, level_slug, lesson_id, teacher_id))
571
+
572
+ # simplest and safest: rebuild sections in order
573
+ cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
574
+ for i, sec in enumerate(sections, start=1):
575
+ cur.execute("""
576
+ INSERT INTO lesson_sections(lesson_id,position,title,content)
577
+ VALUES(%s,%s,%s,%s)
578
+ """, (lesson_id, i, sec.get("title"), sec.get("content")))
579
+ return True
580
+
581
+
582
+ # --- Class progress overview (overall progress, quiz performance, totals)
583
+ def class_progress_overview(class_id: int):
584
+ """
585
+ Returns:
586
+ {
587
+ "overall_progress": 0..1,
588
+ "quiz_performance": 0..1,
589
+ "lessons_completed": int,
590
+ "class_xp": int
591
+ }
592
+ """
593
+ with cursor() as cur:
594
+ # total distinct lessons assigned to this class
595
+ cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s", (class_id,))
596
+ total_assigned = int((cur.fetchone() or {"n": 0})["n"])
597
+
598
+ # number of enrolled students
599
+ cur.execute("SELECT COUNT(*) AS n FROM class_students WHERE class_id=%s", (class_id,))
600
+ num_students = int((cur.fetchone() or {"n": 0})["n"])
601
+
602
+ # sum of completed lessons by all students (for assigned lessons)
603
+ cur.execute("""
604
+ WITH assigned AS (
605
+ SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s
606
+ ), enrolled AS (
607
+ SELECT student_id FROM class_students WHERE class_id = %s
608
+ ), per_student AS (
609
+ SELECT e.student_id,
610
+ COUNT(DISTINCT CASE
611
+ WHEN lp.status='completed' AND lp.lesson_id IN (SELECT lesson_id FROM assigned)
612
+ THEN lp.lesson_id END) AS completed
613
+ FROM enrolled e
614
+ LEFT JOIN lesson_progress lp ON lp.user_id = e.student_id
615
+ GROUP BY e.student_id
616
+ )
617
+ SELECT COALESCE(SUM(completed),0) AS total_completed
618
+ FROM per_student
619
+ """, (class_id, class_id))
620
+ total_completed = int((cur.fetchone() or {"total_completed": 0})["total_completed"] or 0)
621
+
622
+ # quiz performance: average percentage for submissions tied to this class
623
+ cur.execute("""
624
+ SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio
625
+ FROM submissions s
626
+ JOIN assignments a ON a.assignment_id = s.assignment_id
627
+ WHERE a.class_id = %s
628
+ """, (class_id,))
629
+ quiz_perf_row = cur.fetchone() or {"avg_ratio": 0}
630
+ quiz_perf = float(quiz_perf_row["avg_ratio"] or 0)
631
+
632
+ # total class XP (sum of xp for enrolled students)
633
+ cur.execute("""
634
+ SELECT COALESCE(SUM(x.delta),0) AS xp
635
+ FROM xp_log x
636
+ WHERE x.user_id IN (SELECT student_id FROM class_students WHERE class_id=%s)
637
+ """, (class_id,))
638
+ class_xp = int((cur.fetchone() or {"xp": 0})["xp"] or 0)
639
+
640
+ if total_assigned and num_students:
641
+ denominator = float(total_assigned * num_students)
642
+ overall = float(total_completed) / denominator
643
+ else:
644
+ overall = 0.0
645
+
646
+ return dict(
647
+ overall_progress=float(overall),
648
+ quiz_performance=float(quiz_perf),
649
+ lessons_completed=int(total_completed),
650
+ class_xp=int(class_xp),
651
+ )
652
+
653
+ # --- Recent student activity with total_xp for level badge
654
+ def class_recent_activity(class_id:int, limit:int=6, days:int=30):
655
+ """
656
+ Returns latest activity rows with fields:
657
+ ts, kind('lesson'|'quiz'|'game'), student_id, student_name, item_title, extra, total_xp
658
+ """
659
+ with cursor() as cur:
660
+ cur.execute(f"""
661
+ WITH enrolled AS (
662
+ SELECT student_id FROM class_students WHERE class_id = %s
663
+ ),
664
+ xp AS (
665
+ SELECT user_id, COALESCE(SUM(delta),0) AS total_xp
666
+ FROM xp_log GROUP BY user_id
667
+ )
668
+ SELECT * FROM (
669
+ /* completed lessons */
670
+ SELECT lp.last_accessed AS ts,
671
+ 'lesson' AS kind,
672
+ u.user_id AS student_id,
673
+ u.name AS student_name,
674
+ l.title AS item_title,
675
+ NULL AS extra,
676
+ COALESCE(xp.total_xp,0) AS total_xp
677
+ FROM lesson_progress lp
678
+ JOIN enrolled e ON e.student_id = lp.user_id
679
+ JOIN users u ON u.user_id = lp.user_id
680
+ JOIN lessons l ON l.lesson_id = lp.lesson_id
681
+ LEFT JOIN xp ON xp.user_id = u.user_id
682
+ WHERE lp.status = 'completed' AND lp.last_accessed >= NOW() - INTERVAL {days} DAY
683
+
684
+ UNION ALL
685
+
686
+ /* quiz submissions */
687
+ SELECT s.submitted_at AS ts,
688
+ 'quiz' AS kind,
689
+ u.user_id AS student_id,
690
+ u.name AS student_name,
691
+ l.title AS item_title,
692
+ CONCAT(ROUND(s.score*100.0/NULLIF(s.total,0)),'%') AS extra,
693
+ COALESCE(xp.total_xp,0) AS total_xp
694
+ FROM submissions s
695
+ JOIN assignments a ON a.assignment_id = s.assignment_id AND a.class_id = %s
696
+ JOIN users u ON u.user_id = s.student_id
697
+ JOIN lessons l ON l.lesson_id = a.lesson_id
698
+ LEFT JOIN xp ON xp.user_id = u.user_id
699
+ WHERE s.submitted_at >= NOW() - INTERVAL {days} DAY
700
+
701
+ UNION ALL
702
+
703
+ /* games */
704
+ SELECT g.started_at AS ts,
705
+ 'game' AS kind,
706
+ u.user_id AS student_id,
707
+ u.name AS student_name,
708
+ g.game_slug AS item_title,
709
+ NULL AS extra,
710
+ COALESCE(xp.total_xp,0) AS total_xp
711
+ FROM game_sessions g
712
+ JOIN enrolled e ON e.student_id = g.user_id
713
+ JOIN users u ON u.user_id = g.user_id
714
+ LEFT JOIN xp ON xp.user_id = u.user_id
715
+ WHERE g.started_at >= NOW() - INTERVAL {days} DAY
716
+ ) x
717
+ ORDER BY ts DESC
718
+ LIMIT %s
719
+ """, (class_id, class_id, limit))
720
+ return cur.fetchall()
721
+
722
+
723
+
724
+ def list_quizzes_by_teacher(teacher_id:int):
725
+ with cursor() as cur:
726
+ cur.execute("""
727
+ SELECT q.quiz_id, q.title, q.created_at,
728
+ l.title AS lesson_title,
729
+ (SELECT COUNT(*) FROM quiz_items qi WHERE qi.quiz_id=q.quiz_id) AS num_items
730
+ FROM quizzes q
731
+ JOIN lessons l ON l.lesson_id=q.lesson_id
732
+ WHERE l.teacher_id=%s
733
+ ORDER BY q.created_at DESC
734
+ """, (teacher_id,))
735
+ return cur.fetchall()
736
+
737
+ def list_all_students_for_teacher(teacher_id:int):
738
+ with cursor() as cur:
739
+ cur.execute("""
740
+ SELECT DISTINCT u.user_id, u.name, u.email
741
+ FROM classes c
742
+ JOIN class_students cs ON cs.class_id=c.class_id
743
+ JOIN users u ON u.user_id=cs.student_id
744
+ WHERE c.teacher_id=%s
745
+ ORDER BY u.name
746
+ """, (teacher_id,))
747
+ return cur.fetchall()
748
+
749
+ # ----- ASSIGNEES (students) -----
750
+
751
+ def list_assigned_students_for_lesson(lesson_id:int):
752
+ with cursor() as cur:
753
+ cur.execute("""
754
+ WITH direct AS (
755
+ SELECT student_id FROM assignments
756
+ WHERE lesson_id=%s AND student_id IS NOT NULL
757
+ ),
758
+ via_class AS (
759
+ SELECT cs.student_id
760
+ FROM assignments a
761
+ JOIN class_students cs ON cs.class_id=a.class_id
762
+ WHERE a.lesson_id=%s AND a.class_id IS NOT NULL
763
+ ),
764
+ all_students AS (
765
+ SELECT student_id FROM direct
766
+ UNION
767
+ SELECT student_id FROM via_class
768
+ )
769
+ SELECT u.user_id, u.name, u.email
770
+ FROM users u
771
+ JOIN all_students s ON s.student_id=u.user_id
772
+ ORDER BY u.name
773
+ """, (lesson_id, lesson_id))
774
+ return cur.fetchall()
775
+
776
+ def list_assigned_students_for_quiz(quiz_id:int):
777
+ with cursor() as cur:
778
+ cur.execute("""
779
+ WITH direct AS (
780
+ SELECT student_id FROM assignments
781
+ WHERE quiz_id=%s AND student_id IS NOT NULL
782
+ ),
783
+ via_class AS (
784
+ SELECT cs.student_id
785
+ FROM assignments a
786
+ JOIN class_students cs ON cs.class_id=a.class_id
787
+ WHERE a.quiz_id=%s AND a.class_id IS NOT NULL
788
+ ),
789
+ all_students AS (
790
+ SELECT student_id FROM direct
791
+ UNION
792
+ SELECT student_id FROM via_class
793
+ )
794
+ SELECT u.user_id, u.name, u.email
795
+ FROM users u
796
+ JOIN all_students s ON s.student_id=u.user_id
797
+ ORDER BY u.name
798
+ """, (quiz_id, quiz_id))
799
+ return cur.fetchall()
800
+
801
+ # ----- ASSIGN ACTIONS -----
802
+
803
+ def assign_lesson_to_students(lesson_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
804
+ # bulk insert; quiz_id stays NULL
805
+ with cursor() as cur:
806
+ for sid in student_ids:
807
+ cur.execute("""
808
+ INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
809
+ VALUES(%s, NULL, %s, %s, %s)
810
+ ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
811
+ """, (lesson_id, sid, teacher_id, due_at))
812
+ return True
813
+
814
+ def assign_quiz_to_students(quiz_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None):
815
+ # get lesson_id for integrity
816
+ with cursor() as cur:
817
+ cur.execute("SELECT lesson_id FROM quizzes WHERE quiz_id=%s", (quiz_id,))
818
+ row = cur.fetchone()
819
+ if not row:
820
+ raise ValueError("Quiz not found")
821
+ lesson_id = row["lesson_id"]
822
+ for sid in student_ids:
823
+ cur.execute("""
824
+ INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at)
825
+ VALUES(%s, %s, %s, %s, %s)
826
+ ON DUPLICATE KEY UPDATE due_at=VALUES(due_at)
827
+ """, (lesson_id, quiz_id, sid, teacher_id, due_at))
828
+ return True
829
+
830
+ # ----- SAFE DELETE -----
831
+
832
+ def delete_lesson(lesson_id:int, teacher_id:int):
833
+ with cursor() as cur:
834
+ # ownership check
835
+ cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
836
+ if not cur.fetchone():
837
+ return False, "You can only delete own lesson."
838
+ # block if assigned or quizzed
839
+ cur.execute("SELECT COUNT(*) AS n FROM assignments WHERE lesson_id=%s", (lesson_id,))
840
+ if cur.fetchone()["n"] > 0:
841
+ return False, "Remove assignments first."
842
+ cur.execute("SELECT COUNT(*) AS n FROM quizzes WHERE lesson_id=%s", (lesson_id,))
843
+ if cur.fetchone()["n"] > 0:
844
+ return False, "Delete quizzes for this lesson first."
845
+ # delete sections then lesson
846
+ cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,))
847
+ cur.execute("DELETE FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id))
848
+ return True, "Deleted."
849
+
850
+ def delete_quiz(quiz_id:int, teacher_id:int):
851
+ with cursor() as cur:
852
+ cur.execute("""
853
+ SELECT 1
854
+ FROM quizzes q JOIN lessons l ON l.lesson_id=q.lesson_id
855
+ WHERE q.quiz_id=%s AND l.teacher_id=%s
856
+ """, (quiz_id, teacher_id))
857
+ if not cur.fetchone():
858
+ return False, "You can only delete own quiz."
859
+ cur.execute("SELECT COUNT(*) AS n FROM submissions WHERE quiz_id=%s", (quiz_id,))
860
+ if cur.fetchone()["n"] > 0:
861
+ return False, "This quiz has submissions. Deleting is blocked."
862
+ cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,))
863
+ cur.execute("DELETE FROM assignments WHERE quiz_id=%s", (quiz_id,))
864
+ cur.execute("DELETE FROM quizzes WHERE quiz_id=%s", (quiz_id,))
865
+ return True, "Deleted."
866
+
867
+
868
+ def _bump_game_stats(user_id:int, slug:str, *, gained_xp:int, matched:int|None=None, level_inc:int=0):
869
+ with cursor() as cur:
870
+ cur.execute("""
871
+ INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
872
+ VALUES(%s,%s,%s,%s,%s)
873
+ ON DUPLICATE KEY UPDATE
874
+ total_xp = total_xp + VALUES(total_xp),
875
+ matches = matches + VALUES(matches),
876
+ level = GREATEST(level, VALUES(level))
877
+ """, (user_id, slug, int(gained_xp), int(matched or 1), level_inc))
878
+
879
+ # ---------- LESSONS ----------
880
+ def create_lesson(teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]):
881
+ with cursor() as cur:
882
+ cur.execute("""
883
+ INSERT INTO lessons(teacher_id,title,description,subject,level,duration_min)
884
+ VALUES(%s,%s,%s,%s,%s,%s)
885
+ """, (teacher_id, title, description, subject, level_slug, 60))
886
+ cur.execute("SELECT LAST_INSERT_ID() AS id")
887
+ lesson_id = cur.fetchone()["id"]
888
+ for i, sec in enumerate(sections, start=1):
889
+ cur.execute("""
890
+ INSERT INTO lesson_sections(lesson_id,position,title,content)
891
+ VALUES(%s,%s,%s,%s)
892
+ """, (lesson_id, i, sec.get("title"), sec.get("content")))
893
+ return lesson_id
894
+
895
+ def list_lessons_by_teacher(teacher_id:int):
896
+ with cursor() as cur:
897
+ cur.execute("SELECT * FROM lessons WHERE teacher_id=%s ORDER BY created_at DESC", (teacher_id,))
898
+ return cur.fetchall()
899
+
900
+ def get_lesson(lesson_id:int):
901
+ with cursor() as cur:
902
+ cur.execute("SELECT * FROM lessons WHERE lesson_id=%s", (lesson_id,))
903
+ lesson = cur.fetchone()
904
+ cur.execute("SELECT * FROM lesson_sections WHERE lesson_id=%s ORDER BY position", (lesson_id,))
905
+ sections = cur.fetchall()
906
+ return {"lesson": lesson, "sections": sections}
907
+
908
+ # ---------- QUIZZES ----------
909
+ def create_quiz(lesson_id:int, title:str, items:list[dict], settings:dict|None=None):
910
+ with cursor() as cur:
911
+ cur.execute("INSERT INTO quizzes(lesson_id,title,settings) VALUES(%s,%s,%s)",
912
+ (lesson_id, title, json.dumps(settings or {})))
913
+ cur.execute("SELECT LAST_INSERT_ID() AS id")
914
+ quiz_id = cur.fetchone()["id"]
915
+ for i, it in enumerate(items, start=1):
916
+ cur.execute("""
917
+ INSERT INTO quiz_items(quiz_id,position,question,options,answer_key,points)
918
+ VALUES(%s,%s,%s,%s,%s,%s)
919
+ """, (quiz_id, i, it["question"], json.dumps(it.get("options", [])),
920
+ json.dumps(it.get("answer_key")), int(it.get("points", 1))))
921
+ return quiz_id
922
+
923
+ def get_quiz(quiz_id:int):
924
+ with cursor() as cur:
925
+ cur.execute("SELECT * FROM quizzes WHERE quiz_id=%s", (quiz_id,))
926
+ quiz = cur.fetchone()
927
+ cur.execute("SELECT * FROM quiz_items WHERE quiz_id=%s ORDER BY position", (quiz_id,))
928
+ items = cur.fetchall()
929
+ return {"quiz": quiz, "items": items}
930
+
931
+ # ---------- ASSIGNMENTS ----------
932
+ def assign_to_class(lesson_id:int, quiz_id:int|None, class_id:int, teacher_id:int, due_at:str|None=None):
933
+ with cursor() as cur:
934
+ cur.execute("""
935
+ INSERT INTO assignments(lesson_id,quiz_id,class_id,assigned_by,due_at)
936
+ VALUES(%s,%s,%s,%s,%s)
937
+ """, (lesson_id, quiz_id, class_id, teacher_id, due_at))
938
+ cur.execute("SELECT LAST_INSERT_ID() AS id")
939
+ return cur.fetchone()["id"]
940
+
941
+ def assign_to_student(lesson_id:int, quiz_id:int|None, student_id:int, teacher_id:int, due_at:str|None=None):
942
+ with cursor() as cur:
943
+ cur.execute("""
944
+ INSERT INTO assignments(lesson_id,quiz_id,student_id,assigned_by,due_at)
945
+ VALUES(%s,%s,%s,%s,%s)
946
+ """, (lesson_id, quiz_id, student_id, teacher_id, due_at))
947
+ cur.execute("SELECT LAST_INSERT_ID() AS id")
948
+ return cur.fetchone()["id"]
949
+
950
+ def list_assignments_for_student(student_id:int):
951
+ with cursor() as cur:
952
+ cur.execute("""
953
+ SELECT
954
+ a.assignment_id, a.lesson_id, l.title, l.subject, l.level,
955
+ a.quiz_id, a.due_at,
956
+ COALESCE(lp.status,'not_started') AS status,
957
+ lp.current_pos
958
+ FROM (
959
+ SELECT
960
+ a.*,
961
+ ROW_NUMBER() OVER (
962
+ PARTITION BY a.lesson_id
963
+ ORDER BY a.created_at DESC, a.assignment_id DESC
964
+ ) AS rn
965
+ FROM assignments a
966
+ WHERE a.student_id = %s
967
+ OR a.class_id IN (SELECT class_id FROM class_students WHERE student_id = %s)
968
+ ) AS a
969
+ JOIN lessons l ON l.lesson_id = a.lesson_id
970
+ LEFT JOIN lesson_progress lp
971
+ ON lp.user_id = %s AND lp.lesson_id = a.lesson_id
972
+ WHERE a.rn = 1
973
+ ORDER BY a.created_at DESC
974
+ """, (student_id, student_id, student_id))
975
+ return cur.fetchall()
976
+
977
+
978
+ # ---------- PROGRESS and SUBMISSIONS ----------
979
+ def save_progress(user_id:int, lesson_id:int, current_pos:int, status:str):
980
+ with cursor() as cur:
981
+ cur.execute("""
982
+ INSERT INTO lesson_progress(user_id,lesson_id,current_pos,status)
983
+ VALUES(%s,%s,%s,%s)
984
+ ON DUPLICATE KEY UPDATE current_pos=VALUES(current_pos), status=VALUES(status)
985
+ """, (user_id, lesson_id, current_pos, status))
986
+ return True
987
+
988
+ def submit_quiz(student_id:int, assignment_id:int, quiz_id:int, score:int, total:int, details:dict):
989
+ with cursor() as cur:
990
+ cur.execute("""
991
+ INSERT INTO submissions(assignment_id,quiz_id,student_id,score,total,details)
992
+ VALUES(%s,%s,%s,%s,%s,%s)
993
+ ON DUPLICATE KEY UPDATE score=VALUES(score), total=VALUES(total), details=VALUES(details), submitted_at=CURRENT_TIMESTAMP
994
+ """, (assignment_id, quiz_id, student_id, score, total, json.dumps(details)))
995
+
996
+ return True
997
+
998
+ # ---------- DASHBOARD SHORTCUTS ----------
999
+ def teacher_tiles(teacher_id:int):
1000
+ with cursor() as cur:
1001
+ cur.execute("SELECT * FROM v_class_stats WHERE teacher_id=%s", (teacher_id,))
1002
+ rows = cur.fetchall()
1003
+ total_students = sum(r["total_students"] for r in rows)
1004
+ lessons_created = _count_lessons(teacher_id)
1005
+ # use simple averages; adjust later as needed
1006
+ class_avg = round(sum(r["class_avg"] for r in rows)/len(rows), 2) if rows else 0
1007
+ active_students = sum(1 for r in rows if r.get("recent_submissions",0) > 0)
1008
+ return dict(total_students=total_students, class_avg=class_avg, lessons_created=lessons_created, active_students=active_students)
1009
+
1010
+ def _count_lessons(teacher_id:int):
1011
+ with cursor() as cur:
1012
+ cur.execute("SELECT COUNT(*) AS n FROM lessons WHERE teacher_id=%s", (teacher_id,))
1013
+ return cur.fetchone()["n"]
1014
+
1015
+
1016
+ # --- XP and streak helpers ---
1017
+ def user_xp_and_level(user_id: int, base: int = 500):
1018
+ with cursor() as cur:
1019
+ cur.execute("SELECT COALESCE(SUM(delta),0) AS xp FROM xp_log WHERE user_id=%s", (user_id,))
1020
+ xp = int((cur.fetchone() or {"xp": 0})["xp"])
1021
+ cur.execute("SELECT COALESCE(days,0) AS days FROM streaks WHERE user_id=%s", (user_id,))
1022
+ streak = int((cur.fetchone() or {"days": 0})["days"])
1023
+
1024
+ # level math
1025
+ level = max(1, xp // base + 1)
1026
+ start_of_level = (level - 1) * base
1027
+ into = xp - start_of_level
1028
+ need = base
1029
+ # exact boundary should flip level and reset progress
1030
+ if into == need:
1031
+ level += 1
1032
+ into = 0
1033
+
1034
+ return {
1035
+ "xp": xp, # lifetime XP from the DB
1036
+ "level": level, # current level
1037
+ "into": into, # XP inside this level
1038
+ "need": need, # XP needed to reach next level
1039
+ "streak": streak,
1040
+ }
1041
+
1042
+ def recent_lessons_for_student(user_id:int, limit:int=5):
1043
+ with cursor() as cur:
1044
+ cur.execute("""
1045
+ SELECT l.title,
1046
+ CASE WHEN lp.status='completed' THEN 100
1047
+ WHEN lp.current_pos IS NULL THEN 0
1048
+ ELSE LEAST(95, lp.current_pos * 10)
1049
+ END AS progress
1050
+ FROM lessons l
1051
+ LEFT JOIN lesson_progress lp
1052
+ ON lp.lesson_id=l.lesson_id AND lp.user_id=%s
1053
+ WHERE l.lesson_id IN (
1054
+ SELECT lesson_id FROM assignments
1055
+ WHERE student_id=%s
1056
+ OR class_id IN (SELECT class_id FROM class_students WHERE student_id=%s)
1057
+ )
1058
+ ORDER BY l.created_at DESC
1059
+ LIMIT %s
1060
+ """, (user_id, user_id, user_id, limit))
1061
+ return cur.fetchall()
1062
+
1063
+ def student_quiz_average(student_id: int) -> int:
1064
+ """
1065
+ Returns the student's average quiz percentage (0–100) using the latest
1066
+ submission per quiz from the `submissions` table.
1067
+ """
1068
+ with cursor() as cur:
1069
+ cur.execute("""
1070
+ WITH latest AS (
1071
+ SELECT quiz_id, MAX(submitted_at) AS last_ts
1072
+ FROM submissions
1073
+ WHERE student_id = %s
1074
+ GROUP BY quiz_id
1075
+ )
1076
+ SELECT ROUND(AVG(s.score * 100.0 / NULLIF(s.total,0))) AS pct
1077
+ FROM latest t
1078
+ JOIN submissions s
1079
+ ON s.quiz_id = t.quiz_id
1080
+ AND s.submitted_at = t.last_ts
1081
+ WHERE s.student_id = %s
1082
+ """, (student_id, student_id))
1083
+ row = cur.fetchone() or {}
1084
+ return int(row.get("pct") or 0)
1085
+
1086
+ # --- Generic XP bump and streak touch ---
1087
+ def add_xp(user_id:int, delta:int, source:str, meta:dict|None=None):
1088
+ with cursor() as cur:
1089
+ cur.execute(
1090
+ "INSERT INTO xp_log(user_id,source,delta,meta) VALUES(%s,%s,%s,%s)",
1091
+ (user_id, source, int(delta), json.dumps(meta or {}))
1092
+ )
1093
+ # streak touch
1094
+ cur.execute("SELECT days, last_active FROM streaks WHERE user_id=%s", (user_id,))
1095
+ row = cur.fetchone()
1096
+ today = date.today()
1097
+ if not row:
1098
+ cur.execute("INSERT INTO streaks(user_id,days,last_active) VALUES(%s,%s,%s)", (user_id, 1, today))
1099
+ else:
1100
+ last = row["last_active"]
1101
+ days = int(row["days"] or 0)
1102
+ if last is None or last < today:
1103
+ # if we missed a day, reset to 1 else +1
1104
+ if last and (today - last) > timedelta(days=1):
1105
+ days = 1
1106
+ else:
1107
+ days = max(1, days + 1)
1108
+ cur.execute("UPDATE streaks SET days=%s,last_active=%s WHERE user_id=%s", (days, today, user_id))
1109
+
1110
+ # -- leaderboard helpders ---
1111
+
1112
+ def leaderboard_for_class(class_id: int, limit: int = 10):
1113
+ """
1114
+ Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
1115
+ Sorted by XP (desc) for students in a specific class.
1116
+ """
1117
+ with cursor() as cur:
1118
+ cur.execute("""
1119
+ SELECT
1120
+ u.user_id,
1121
+ u.name,
1122
+ COALESCE(x.total_xp, 0) AS xp
1123
+ FROM class_students cs
1124
+ JOIN users u ON u.user_id = cs.student_id
1125
+ LEFT JOIN (
1126
+ SELECT user_id, SUM(delta) AS total_xp
1127
+ FROM xp_log
1128
+ GROUP BY user_id
1129
+ ) x ON x.user_id = u.user_id
1130
+ WHERE cs.class_id = %s
1131
+ ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
1132
+ LIMIT %s
1133
+ """, (class_id, limit))
1134
+ rows = cur.fetchall() or []
1135
+ # attach levels using curve
1136
+ for r in rows:
1137
+ r["level"] = level_from_xp(r.get("xp", 0))
1138
+ return rows
1139
+
1140
+
1141
+ def leaderboard_global(limit: int = 10):
1142
+ """
1143
+ Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...]
1144
+ Top students across the whole app by XP.
1145
+ """
1146
+ with cursor() as cur:
1147
+ cur.execute("""
1148
+ SELECT
1149
+ u.user_id,
1150
+ u.name,
1151
+ COALESCE(x.total_xp, 0) AS xp
1152
+ FROM users u
1153
+ LEFT JOIN (
1154
+ SELECT user_id, SUM(delta) AS total_xp
1155
+ FROM xp_log
1156
+ GROUP BY user_id
1157
+ ) x ON x.user_id = u.user_id
1158
+ WHERE u.role_slug = 'student'
1159
+ ORDER BY COALESCE(x.total_xp, 0) DESC, u.name
1160
+ LIMIT %s
1161
+ """, (limit,))
1162
+ rows = cur.fetchall() or []
1163
+ for r in rows:
1164
+ r["level"] = level_from_xp(r.get("xp", 0))
1165
+ return rows
1166
+
1167
+
1168
+
1169
+
1170
+ # --- Game logging helpers ---
1171
+ def record_money_match_play(user_id:int, *, target:int, total:int, elapsed_ms:int, matched:bool, gained_xp:int):
1172
+ with cursor() as cur:
1173
+ cur.execute("""
1174
+ INSERT INTO game_sessions(user_id,game_slug,target,total,elapsed_ms,matched,gained_xp,ended_at)
1175
+ VALUES(%s,'money_match',%s,%s,%s,%s,%s,NOW())
1176
+ """, (user_id, target, total, elapsed_ms, 1 if matched else 0, gained_xp))
1177
+ cur.execute("""
1178
+ INSERT INTO money_match_history(user_id,target,total,elapsed_ms,gained_xp,matched)
1179
+ VALUES(%s,%s,%s,%s,%s,%s)
1180
+ """, (user_id, target, total, elapsed_ms, gained_xp, 1 if matched else 0))
1181
+ cur.execute("""
1182
+ INSERT INTO money_match_stats(user_id,total_xp,matches,best_time_ms,best_target)
1183
+ VALUES(%s,%s,%s,%s,%s)
1184
+ ON DUPLICATE KEY UPDATE
1185
+ total_xp = total_xp + VALUES(total_xp),
1186
+ matches = matches + VALUES(matches),
1187
+ best_time_ms = LEAST(COALESCE(best_time_ms, VALUES(best_time_ms)), VALUES(best_time_ms)),
1188
+ best_target = COALESCE(best_target, VALUES(best_target))
1189
+ """, (user_id, gained_xp, 1 if matched else 0, elapsed_ms if matched else None, target if matched else None))
1190
+
1191
+ _bump_game_stats(user_id, "money_match", gained_xp=gained_xp, matched=1 if matched else 0)
1192
+ add_xp(user_id, gained_xp, "game", {"game":"money_match","target":target,"total":total,"elapsed_ms":elapsed_ms,"matched":matched})
1193
+
1194
+ def record_budget_builder_save(user_id:int, *, weekly_allowance:int, allocations:list[dict]):
1195
+ total_allocated = sum(int(x.get("amount",0)) for x in allocations)
1196
+ remaining = int(weekly_allowance) - total_allocated
1197
+ gained_xp = 150 if remaining == 0 else 100 if remaining > 0 else 50
1198
+ with cursor() as cur:
1199
+ cur.execute("""
1200
+ INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
1201
+ VALUES(%s,'budget_builder',%s,NOW())
1202
+ """, (user_id, gained_xp))
1203
+ cur.execute("""
1204
+ INSERT INTO budget_builder_history(user_id,weekly_allowance,allocations,total_allocated,remaining,gained_xp)
1205
+ VALUES(%s,%s,%s,%s,%s,%s)
1206
+ """, (user_id, weekly_allowance, json.dumps(allocations), total_allocated, remaining, gained_xp))
1207
+ cur.execute("""
1208
+ INSERT INTO budget_builder_stats(user_id,total_xp,plays,best_balance)
1209
+ VALUES(%s,%s,1,%s)
1210
+ ON DUPLICATE KEY UPDATE
1211
+ total_xp = total_xp + VALUES(total_xp),
1212
+ plays = plays + 1,
1213
+ best_balance = GREATEST(COALESCE(best_balance, 0), VALUES(best_balance))
1214
+ """, (user_id, gained_xp, remaining))
1215
+
1216
+ _bump_game_stats(user_id, "budget_builder", gained_xp=gained_xp, matched=1)
1217
+ add_xp(user_id, gained_xp, "game", {"game":"budget_builder","remaining":remaining})
1218
+
1219
+ def record_debt_dilemma_round(
1220
+ user_id:int, *,
1221
+ level:int, round_no:int,
1222
+ wallet:int, health:int, happiness:int, credit_score:int,
1223
+ event_json:dict, outcome:str, gained_xp:int
1224
+ ):
1225
+ with cursor() as cur:
1226
+ cur.execute("""
1227
+ INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
1228
+ VALUES(%s,'debt_dilemma',%s,NOW())
1229
+ """, (user_id, gained_xp))
1230
+ cur.execute("""
1231
+ INSERT INTO debt_dilemma_history(user_id,level,round_no,wallet,health,happiness,credit_score,event_json,outcome,gained_xp)
1232
+ VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
1233
+ """, (user_id, level, round_no, wallet, health, happiness, credit_score, json.dumps(event_json or {}), outcome, gained_xp))
1234
+ cur.execute("""
1235
+ INSERT INTO debt_dilemma_stats(user_id,total_xp,plays,highest_level,last_outcome)
1236
+ VALUES(%s,%s,1,%s,%s)
1237
+ ON DUPLICATE KEY UPDATE
1238
+ total_xp = total_xp + VALUES(total_xp),
1239
+ plays = plays + 1,
1240
+ highest_level = GREATEST(COALESCE(highest_level,0), VALUES(highest_level)),
1241
+ last_outcome = VALUES(last_outcome)
1242
+ """, (user_id, gained_xp, level, outcome))
1243
+
1244
+ # Treat a completed month/level as a "match"
1245
+ _bump_game_stats(user_id, "debt_dilemma", gained_xp=gained_xp, matched=1, level_inc=level)
1246
+ add_xp(user_id, gained_xp, "game", {
1247
+ "game":"debt_dilemma","level":level,"round":round_no,"outcome":outcome
1248
+ })
1249
+
1250
+
1251
+ def record_profit_puzzle_result(
1252
+ user_id:int, *,
1253
+ scenario_id:str,
1254
+ title:str,
1255
+ units:int, price:int, cost:int,
1256
+ user_answer:float, actual_profit:float,
1257
+ is_correct:bool, gained_xp:int
1258
+ ):
1259
+ with cursor() as cur:
1260
+ # generic session row for cross-game views
1261
+ cur.execute("""
1262
+ INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at)
1263
+ VALUES(%s,'profit_puzzle',%s,NOW())
1264
+ """, (user_id, int(gained_xp)))
1265
+
1266
+ # detailed history
1267
+ cur.execute("""
1268
+ INSERT INTO profit_puzzle_history
1269
+ (user_id,scenario_id,title,units,price,cost,user_answer,actual_profit,is_correct,gained_xp)
1270
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
1271
+ """, (user_id, scenario_id, title, int(units), int(price), int(cost),
1272
+ float(user_answer), float(actual_profit), 1 if is_correct else 0, int(gained_xp)))
1273
+
1274
+ # per-game stats
1275
+ cur.execute("""
1276
+ INSERT INTO profit_puzzle_stats(user_id,total_xp,plays,correct,last_score)
1277
+ VALUES(%s,%s,1,%s,%s)
1278
+ ON DUPLICATE KEY UPDATE
1279
+ total_xp = total_xp + VALUES(total_xp),
1280
+ plays = plays + 1,
1281
+ correct = correct + VALUES(correct),
1282
+ last_score = VALUES(last_score),
1283
+ last_played = CURRENT_TIMESTAMP
1284
+ """, (user_id, int(gained_xp), 1 if is_correct else 0, int(gained_xp)))
1285
+
1286
+ # game_stats rollup like other games
1287
+ cur.execute("""
1288
+ INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level)
1289
+ VALUES(%s,'profit_puzzle',%s,%s,1)
1290
+ ON DUPLICATE KEY UPDATE
1291
+ total_xp = total_xp + VALUES(total_xp),
1292
+ matches = matches + VALUES(matches)
1293
+ """, (user_id, int(gained_xp), 1 if is_correct else 0))
1294
+
1295
+ # global XP and streak
1296
+ add_xp(user_id, int(gained_xp), "game",
1297
+ {"game":"profit_puzzle","scenario":scenario_id,"correct":bool(is_correct)})
1298
+
1299
+ # --- Profit Puzzle logging ---
1300
+ def record_profit_puzzle_progress(user_id:int, *, scenario_title:str, correct:bool, gained_xp:int):
1301
+ """
1302
+ Log a Profit Puzzle step and bump XP.
1303
+ - Writes to generic game_sessions and game_stats
1304
+ - Writes to xp_log via add_xp
1305
+ """
1306
+ with cursor() as cur:
1307
+ # session line item
1308
+ cur.execute("""
1309
+ INSERT INTO game_sessions(user_id, game_slug, gained_xp, ended_at)
1310
+ VALUES(%s, 'profit_puzzle', %s, NOW())
1311
+ """, (user_id, int(gained_xp)))
1312
+
1313
+ # aggregate by game
1314
+ cur.execute("""
1315
+ INSERT INTO game_stats(user_id, game_slug, total_xp, matches, level)
1316
+ VALUES(%s, 'profit_puzzle', %s, %s, 1)
1317
+ ON DUPLICATE KEY UPDATE
1318
+ total_xp = total_xp + VALUES(total_xp),
1319
+ matches = matches + VALUES(matches)
1320
+ """, (user_id, int(gained_xp), 1 if correct else 0))
1321
+
1322
+ add_xp(
1323
+ user_id,
1324
+ int(gained_xp),
1325
+ "game",
1326
+ {"game": "profit_puzzle", "scenario": scenario_title, "correct": bool(correct)}
1327
+ )
utils/quizdata.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ quizzes_data = {
2
+ 1: {
3
+ "title": "Module 1: Budgeting Basics",
4
+ "description": "Learn the fundamentals of budgeting and money management",
5
+ "level": "Beginner",
6
+ "duration": "15 min",
7
+ "questions": [
8
+ {
9
+ "question": "Which of these is considered a 'need' rather than a 'want'?",
10
+ "options": ["Latest smartphone", "Designer clothes", "Basic shelter and housing", "Streaming subscriptions"],
11
+ "answer": "Basic shelter and housing",
12
+ "explanation": "Shelter is essential for survival, while others are lifestyle wants."
13
+ },
14
+ {
15
+ "question": "What is the 50/30/20 budgeting rule?",
16
+ "options": [
17
+ "50% needs, 30% wants, 20% savings",
18
+ "30% needs, 50% wants, 20% savings",
19
+ "20% needs, 50% savings, 30% wants",
20
+ "40% needs, 40% wants, 20% savings"
21
+ ],
22
+ "answer": "50% needs, 30% wants, 20% savings",
23
+ "explanation": "This rule allocates income to essentials, lifestyle, and savings/debt repayment."
24
+ },
25
+ {
26
+ "question": "What should you do first when creating a budget?",
27
+ "options": [
28
+ "Track your income and expenses for a month",
29
+ "Get a loan",
30
+ "Buy less food",
31
+ "Invest in stocks"
32
+ ],
33
+ "answer": "Track your income and expenses for a month",
34
+ "explanation": "Tracking helps you understand spending patterns before planning a budget."
35
+ },
36
+ {
37
+ "question": "How often should you review and update your budget?",
38
+ "options": ["Every week", "Monthly", "Yearly", "Never"],
39
+ "answer": "Monthly",
40
+ "explanation": "Budgets should be reviewed monthly to reflect financial changes."
41
+ },
42
+ {
43
+ "question": "What percentage of income should ideally go to housing costs?",
44
+ "options": ["10% or less", "30% or less", "50% or less", "70% or less"],
45
+ "answer": "30% or less",
46
+ "explanation": "Experts recommend keeping housing costs at 30% or less of gross income."
47
+ },
48
+ ]
49
+ },
50
+ 2: {
51
+ "title": "Module 2: Saving & Emergency Funds",
52
+ "description": "Master the art of saving and building financial security",
53
+ "level": "Beginner",
54
+ "duration": "12 min",
55
+ "questions": [
56
+ {
57
+ "question": "What is the primary purpose of an emergency fund?",
58
+ "options": [
59
+ "To buy luxury items",
60
+ "To cover unexpected expenses",
61
+ "To invest in the stock market",
62
+ "To pay monthly bills"
63
+ ],
64
+ "answer": "To cover unexpected expenses",
65
+ "explanation": "An emergency fund provides financial security during unplanned situations."
66
+ },
67
+ {
68
+ "question": "How much should you ideally save in an emergency fund?",
69
+ "options": [
70
+ "1 month of expenses",
71
+ "3–6 months of expenses",
72
+ "12 months of expenses",
73
+ "No fixed amount"
74
+ ],
75
+ "answer": "3–6 months of expenses",
76
+ "explanation": "Experts recommend saving enough to cover 3–6 months of living expenses."
77
+ },
78
+ {
79
+ "question": "Where should you keep your emergency fund?",
80
+ "options": [
81
+ "In a checking or savings account",
82
+ "In risky stocks",
83
+ "In real estate",
84
+ "Locked in a retirement account"
85
+ ],
86
+ "answer": "In a checking or savings account",
87
+ "explanation": "Emergency funds should be liquid and easily accessible."
88
+ },
89
+ {
90
+ "question": "What is the difference between saving and investing?",
91
+ "options": [
92
+ "Saving is riskier than investing",
93
+ "Investing is short-term, saving is long-term",
94
+ "Saving is for safety, investing is for growth",
95
+ "They are the same thing"
96
+ ],
97
+ "answer": "Saving is for safety, investing is for growth",
98
+ "explanation": "Savings are secure, while investments aim for higher returns with more risk."
99
+ },
100
+ ]
101
+ },
102
+ 3: {
103
+ "title": "Module 3: Investment Fundamentals",
104
+ "description": "Understanding the basics of investing and growing wealth",
105
+ "level": "Intermediate",
106
+ "duration": "20 min",
107
+ "questions": [
108
+ {
109
+ "question": "Which of these is considered a low-risk investment?",
110
+ "options": ["Stocks", "Bonds", "Cryptocurrency", "Options trading"],
111
+ "answer": "Bonds",
112
+ "explanation": "Bonds are generally safer than stocks and other volatile investments."
113
+ },
114
+ {
115
+ "question": "What is diversification in investing?",
116
+ "options": [
117
+ "Putting all money into one stock",
118
+ "Spreading investments across different assets",
119
+ "Investing only in foreign companies",
120
+ "Investing only in real estate"
121
+ ],
122
+ "answer": "Spreading investments across different assets",
123
+ "explanation": "Diversification reduces risk by not relying on a single asset."
124
+ },
125
+ {
126
+ "question": "Which investment typically has the highest risk?",
127
+ "options": ["Savings account", "Treasury bonds", "Stocks", "Cryptocurrency"],
128
+ "answer": "Cryptocurrency",
129
+ "explanation": "Cryptocurrencies are highly volatile compared to traditional investments."
130
+ },
131
+ {
132
+ "question": "What does 'compound interest' mean?",
133
+ "options": [
134
+ "Interest earned only on the original deposit",
135
+ "Interest earned on both the deposit and accumulated interest",
136
+ "A type of tax on investments",
137
+ "A penalty for late payments"
138
+ ],
139
+ "answer": "Interest earned on both the deposit and accumulated interest",
140
+ "explanation": "Compound interest accelerates growth by earning interest on interest."
141
+ },
142
+ {
143
+ "question": "Which of these is considered a retirement investment account?",
144
+ "options": ["401(k)", "Credit card", "Checking account", "Car loan"],
145
+ "answer": "401(k)",
146
+ "explanation": "A 401(k) is a retirement savings account that offers tax advantages."
147
+ },
148
+ ]
149
+ },
150
+ 4: {
151
+ "title": "Module 4: Credit & Debt Management",
152
+ "description": "Learn how to manage credit and debt responsibly",
153
+ "level": "Intermediate",
154
+ "duration": "18 min",
155
+ "questions": [
156
+ {
157
+ "question": "Which of these improves your credit score?",
158
+ "options": [
159
+ "Paying bills on time",
160
+ "Maxing out your credit cards",
161
+ "Closing old credit accounts",
162
+ "Missing payments occasionally"
163
+ ],
164
+ "answer": "Paying bills on time",
165
+ "explanation": "On-time payments are the biggest factor in a good credit score."
166
+ },
167
+ {
168
+ "question": "What is a common consequence of only making minimum credit card payments?",
169
+ "options": [
170
+ "You avoid all interest charges",
171
+ "It takes longer to pay off debt with more interest",
172
+ "Your credit score immediately improves",
173
+ "You save money in the long run"
174
+ ],
175
+ "answer": "It takes longer to pay off debt with more interest",
176
+ "explanation": "Minimum payments extend repayment time and increase total interest costs."
177
+ },
178
+ {
179
+ "question": "What is a 'debt-to-income ratio'?",
180
+ "options": [
181
+ "Your income compared to your expenses",
182
+ "Your monthly debt compared to your monthly income",
183
+ "Your total debt compared to your savings",
184
+ "Your credit score number"
185
+ ],
186
+ "answer": "Your monthly debt compared to your monthly income",
187
+ "explanation": "Lenders use this ratio to assess your ability to manage debt."
188
+ },
189
+ {
190
+ "question": "Which strategy is best for paying off multiple debts quickly?",
191
+ "options": [
192
+ "Debt snowball (pay smallest debts first)",
193
+ "Debt avalanche (pay highest interest debts first)",
194
+ "Pay all debts equally",
195
+ "Ignore debts until they go away"
196
+ ],
197
+ "answer": "Debt avalanche (pay highest interest debts first)",
198
+ "explanation": "Debt avalanche minimizes interest payments by targeting high-interest debt first."
199
+ },
200
+ ]
201
+ },
202
+ 5: {
203
+ "title": "General Financial Knowledge",
204
+ "description": "Test your overall financial literacy across all topics",
205
+ "level": "Intermediate",
206
+ "duration": "25 min",
207
+ "questions": [
208
+ {
209
+ "question": "What does 'inflation' mean?",
210
+ "options": [
211
+ "Decrease in overall price levels",
212
+ "Increase in overall price levels",
213
+ "A government tax increase",
214
+ "Stock market growth"
215
+ ],
216
+ "answer": "Increase in overall price levels",
217
+ "explanation": "Inflation is the general rise in prices over time."
218
+ },
219
+ {
220
+ "question": "What is the main purpose of insurance?",
221
+ "options": [
222
+ "To generate investment returns",
223
+ "To protect against financial loss",
224
+ "To avoid paying taxes",
225
+ "To increase monthly expenses"
226
+ ],
227
+ "answer": "To protect against financial loss",
228
+ "explanation": "Insurance transfers financial risk from you to the insurer."
229
+ },
230
+ {
231
+ "question": "What is net income?",
232
+ "options": [
233
+ "Total income before taxes",
234
+ "Income after taxes and deductions",
235
+ "The same as gross income",
236
+ "Investment profits only"
237
+ ],
238
+ "answer": "Income after taxes and deductions",
239
+ "explanation": "Net income is the money you take home after deductions."
240
+ },
241
+ {
242
+ "question": "Which financial product typically has the highest interest rate?",
243
+ "options": ["Mortgage loan", "Credit card", "Student loan", "Car loan"],
244
+ "answer": "Credit card",
245
+ "explanation": "Credit cards usually have higher interest rates than other types of loans."
246
+ },
247
+ {
248
+ "question": "What is diversification in finance?",
249
+ "options": [
250
+ "Spreading money across different assets to reduce risk",
251
+ "Putting all money into one high-performing stock",
252
+ "Avoiding investments completely",
253
+ "Buying only government bonds"
254
+ ],
255
+ "answer": "Spreading money across different assets to reduce risk",
256
+ "explanation": "Diversification reduces exposure to risk by investing in various asset classes."
257
+ },
258
+ ]
259
+ },
260
+ }