lanna_lalala;- commited on
Commit ·
5a8b3c5
1
Parent(s): 4605217
try 3
Browse files- dashboards/teacher_db.py +89 -4
- phase/Student_view/teacherlink.py +108 -33
- phase/Teacher_view/classmanage.py +100 -36
- phase/Teacher_view/contentmanage.py +203 -133
- phase/Teacher_view/studentlist.py +110 -38
- utils/api.py +163 -0
dashboards/teacher_db.py
CHANGED
|
@@ -1,12 +1,16 @@
|
|
| 1 |
# dashboards/teacher_db.py
|
| 2 |
-
import streamlit as st
|
| 3 |
import os
|
| 4 |
-
import plotly.express as px
|
| 5 |
-
import plotly.graph_objects as go
|
| 6 |
-
import csv
|
| 7 |
import io
|
|
|
|
| 8 |
import datetime
|
|
|
|
|
|
|
|
|
|
| 9 |
from utils import db as dbapi
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
def load_css(file_name):
|
| 12 |
try:
|
|
@@ -24,6 +28,87 @@ def tile(icon, label, value):
|
|
| 24 |
</div>
|
| 25 |
"""
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def show_teacher_dashboard():
|
| 28 |
css_path = os.path.join("assets", "styles.css")
|
| 29 |
load_css(css_path)
|
|
|
|
| 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:
|
|
|
|
| 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)
|
phase/Student_view/teacherlink.py
CHANGED
|
@@ -2,6 +2,9 @@
|
|
| 2 |
import os
|
| 3 |
import streamlit as st
|
| 4 |
from utils import db as dbapi
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def load_css(file_name: str):
|
| 7 |
try:
|
|
@@ -18,6 +21,57 @@ def _progress_0_1(v):
|
|
| 18 |
# accept 0..1 or 0..100
|
| 19 |
return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def show_code():
|
| 22 |
load_css(os.path.join("assets", "styles.css"))
|
| 23 |
|
|
@@ -33,7 +87,7 @@ def show_code():
|
|
| 33 |
st.caption("Enter class code from your teacher")
|
| 34 |
|
| 35 |
raw = st.text_input(
|
| 36 |
-
label="Class Code",
|
| 37 |
placeholder="e.g. FIN5A2024",
|
| 38 |
key="class_code_input",
|
| 39 |
label_visibility="collapsed"
|
|
@@ -65,16 +119,18 @@ def show_code():
|
|
| 65 |
st.error("Enter a class code.")
|
| 66 |
else:
|
| 67 |
try:
|
| 68 |
-
|
| 69 |
st.success("🎉 Joined the class!")
|
| 70 |
st.rerun()
|
| 71 |
except ValueError as e:
|
| 72 |
st.error(str(e))
|
|
|
|
|
|
|
| 73 |
|
| 74 |
st.markdown("---")
|
| 75 |
st.markdown("## Your Classes")
|
| 76 |
|
| 77 |
-
classes =
|
| 78 |
if not classes:
|
| 79 |
st.info("You haven’t joined any classes yet. Ask your teacher for a class code.")
|
| 80 |
return
|
|
@@ -82,36 +138,44 @@ def show_code():
|
|
| 82 |
# one card per class
|
| 83 |
for c in classes:
|
| 84 |
class_id = c["class_id"]
|
| 85 |
-
counts =
|
| 86 |
-
prog =
|
| 87 |
|
| 88 |
-
st.markdown(f"### {c
|
| 89 |
-
st.caption(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
st.progress(_progress_0_1(prog
|
|
|
|
| 92 |
st.caption(
|
| 93 |
-
f"{prog
|
| 94 |
-
f"Avg quiz: {
|
| 95 |
)
|
| 96 |
|
| 97 |
# top metrics
|
| 98 |
m1, m2, m3, m4 = st.columns(4)
|
| 99 |
m1.metric("Lessons", counts.get("lessons", 0))
|
| 100 |
m2.metric("Quizzes", counts.get("quizzes", 0))
|
| 101 |
-
m3.metric("Overall", f"{int(round(100*_progress_0_1(prog
|
| 102 |
-
m4.metric("Avg Quiz", f"{
|
| 103 |
|
| 104 |
# Leave class
|
| 105 |
-
leave_col, _ = st.columns([1,3])
|
| 106 |
with leave_col:
|
| 107 |
if st.button("🚪 Leave Class", key=f"leave_{class_id}"):
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
|
| 112 |
# Assignments for THIS class with THIS student's progress
|
| 113 |
st.markdown("#### Teacher Lessons & Quizzes")
|
| 114 |
-
rows =
|
| 115 |
if not rows:
|
| 116 |
st.info("No assignments yet.")
|
| 117 |
else:
|
|
@@ -119,23 +183,29 @@ def show_code():
|
|
| 119 |
|
| 120 |
with lessons_tab:
|
| 121 |
for r in rows:
|
| 122 |
-
if r
|
| 123 |
continue
|
| 124 |
|
| 125 |
status = r.get("status") or "not_started"
|
| 126 |
pos = r.get("current_pos") or 0
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
st.progress(_progress_0_1(pct))
|
| 132 |
|
| 133 |
c1, c2 = st.columns(2)
|
| 134 |
with c1:
|
| 135 |
-
#
|
| 136 |
-
if st.button("▶️ Start Lesson", key=f"start_lesson_{r
|
| 137 |
-
st.session_state.selected_lesson = r
|
| 138 |
-
st.session_state.selected_assignment = r
|
| 139 |
st.session_state.current_page = "Lessons"
|
| 140 |
st.rerun()
|
| 141 |
with c2:
|
|
@@ -144,21 +214,26 @@ def show_code():
|
|
| 144 |
with quizzes_tab:
|
| 145 |
any_quiz = False
|
| 146 |
for r in rows:
|
| 147 |
-
|
|
|
|
| 148 |
continue
|
| 149 |
any_quiz = True
|
| 150 |
|
| 151 |
-
st.subheader(r
|
| 152 |
score, total = r.get("score"), r.get("total")
|
| 153 |
if score is not None and total:
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
else:
|
| 156 |
st.caption("No submission yet")
|
| 157 |
|
| 158 |
-
#
|
| 159 |
-
if st.button("📝 Start Quiz", key=f"start_quiz_{class_id}_{
|
| 160 |
-
st.session_state.selected_quiz =
|
| 161 |
-
st.session_state.current_assignment = r
|
| 162 |
st.session_state.current_page = "Quiz"
|
| 163 |
st.rerun()
|
| 164 |
|
|
|
|
| 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:
|
|
|
|
| 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 |
|
|
|
|
| 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"
|
|
|
|
| 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
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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 |
|
phase/Teacher_view/classmanage.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
| 1 |
# phase/Teacher_view/classmanage.py
|
| 2 |
-
|
| 3 |
import streamlit as st
|
| 4 |
-
|
| 5 |
-
import string
|
| 6 |
-
from datetime import datetime
|
| 7 |
from utils import db as dbapi
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def _metric_card(label: str, value: str, caption: str = ""):
|
| 10 |
st.markdown(
|
|
@@ -18,6 +21,23 @@ def _metric_card(label: str, value: str, caption: str = ""):
|
|
| 18 |
unsafe_allow_html=True,
|
| 19 |
)
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def show_page():
|
| 22 |
user = st.session_state.user
|
| 23 |
teacher_id = user["user_id"]
|
|
@@ -30,29 +50,45 @@ def show_page():
|
|
| 30 |
new_name = st.text_input("Classroom Name", key="new_classroom_name")
|
| 31 |
if st.button("Create Classroom"):
|
| 32 |
name = new_name.strip()
|
| 33 |
-
if name:
|
| 34 |
-
out = dbapi.create_class(teacher_id, name)
|
| 35 |
-
st.session_state.selected_class_id = out["class_id"]
|
| 36 |
-
st.success(f'Classroom "{name}" created with code: {out["code"]}')
|
| 37 |
-
st.rerun()
|
| 38 |
-
else:
|
| 39 |
st.error("Enter a real name, not whitespace.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# -------- Load classes for this teacher --------
|
| 42 |
-
classes =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
if not classes:
|
| 44 |
st.info("No classrooms yet. Create one above, then share the code.")
|
| 45 |
return
|
| 46 |
|
| 47 |
-
# Picker
|
| 48 |
st.subheader("Your Classrooms")
|
| 49 |
-
options = {f"{c
|
| 50 |
selected_label = st.selectbox("Select a classroom", list(options.keys()))
|
| 51 |
selected = options[selected_label]
|
| 52 |
class_id = selected["class_id"]
|
| 53 |
|
| 54 |
st.markdown("---")
|
| 55 |
-
st.header(selected
|
| 56 |
|
| 57 |
# -------- Code stripe --------
|
| 58 |
st.subheader("Class Code")
|
|
@@ -70,14 +106,18 @@ def show_page():
|
|
| 70 |
|
| 71 |
# ============== Students tab ==============
|
| 72 |
with tab_students:
|
| 73 |
-
# search input
|
| 74 |
q = st.text_input("Search students by name or email", "")
|
| 75 |
-
roster =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
# simple filter
|
| 78 |
if q.strip():
|
| 79 |
ql = q.lower()
|
| 80 |
-
roster = [r for r in roster if ql in r
|
| 81 |
|
| 82 |
st.caption(f"{len(roster)} Students Found")
|
| 83 |
|
|
@@ -85,41 +125,65 @@ def show_page():
|
|
| 85 |
st.info("No students in this class yet.")
|
| 86 |
else:
|
| 87 |
for s in roster:
|
| 88 |
-
st.subheader(f"👤 {s
|
| 89 |
-
st.caption(s
|
| 90 |
joined = s.get("joined_at") or s.get("created_at")
|
| 91 |
-
st.caption(f"📅 Joined: {str(joined)[:10]}")
|
| 92 |
-
st.progress(0.0) # placeholder bar
|
| 93 |
cols = st.columns(3)
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
st.markdown("---")
|
| 98 |
|
| 99 |
# ============== Content tab ==============
|
| 100 |
with tab_content:
|
| 101 |
-
counts =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
left, right = st.columns(2)
|
| 103 |
with left:
|
| 104 |
-
_metric_card("📖 Custom Lessons", str(counts
|
| 105 |
with right:
|
| 106 |
-
_metric_card("🏆 Custom Quizzes", str(counts
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
if assigs:
|
| 111 |
st.markdown("#### Assigned items")
|
| 112 |
for a in assigs:
|
| 113 |
-
has_quiz = " + Quiz" if a
|
| 114 |
-
st.markdown(f"- **{a
|
| 115 |
|
| 116 |
# ============== Analytics tab ==============
|
| 117 |
with tab_analytics:
|
| 118 |
-
stats =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
g1, g2, g3 = st.columns(3)
|
| 120 |
with g1:
|
| 121 |
-
_metric_card("📊 Class Average", f"{
|
| 122 |
with g2:
|
| 123 |
-
_metric_card("🪙 Total XP", f"{
|
| 124 |
with g3:
|
| 125 |
-
_metric_card("📘 Lessons Completed", f"{
|
|
|
|
| 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(
|
|
|
|
| 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"]
|
|
|
|
| 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 |
+
# positional args first, then keywords (we only use positional here)
|
| 57 |
+
out = _prefer_db(
|
| 58 |
+
"create_class",
|
| 59 |
+
lambda tid, n: api.create_class(tid, n),
|
| 60 |
+
None,
|
| 61 |
+
teacher_id,
|
| 62 |
+
name,
|
| 63 |
+
)
|
| 64 |
+
if out:
|
| 65 |
+
st.session_state.selected_class_id = out.get("class_id")
|
| 66 |
+
st.success(f'Classroom "{name}" created with code: {out.get("code","—")}')
|
| 67 |
+
st.rerun()
|
| 68 |
+
else:
|
| 69 |
+
st.error("Could not create classroom (no response).")
|
| 70 |
|
| 71 |
# -------- Load classes for this teacher --------
|
| 72 |
+
classes = _prefer_db(
|
| 73 |
+
"list_classes_by_teacher",
|
| 74 |
+
lambda tid: api.list_classes_by_teacher(tid),
|
| 75 |
+
[],
|
| 76 |
+
teacher_id, # positional
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
if not classes:
|
| 80 |
st.info("No classrooms yet. Create one above, then share the code.")
|
| 81 |
return
|
| 82 |
|
| 83 |
+
# Picker
|
| 84 |
st.subheader("Your Classrooms")
|
| 85 |
+
options = {f"{c.get('name','(unnamed)')} (Code: {c.get('code','')})": c for c in classes}
|
| 86 |
selected_label = st.selectbox("Select a classroom", list(options.keys()))
|
| 87 |
selected = options[selected_label]
|
| 88 |
class_id = selected["class_id"]
|
| 89 |
|
| 90 |
st.markdown("---")
|
| 91 |
+
st.header(selected.get("name", "Classroom"))
|
| 92 |
|
| 93 |
# -------- Code stripe --------
|
| 94 |
st.subheader("Class Code")
|
|
|
|
| 106 |
|
| 107 |
# ============== Students tab ==============
|
| 108 |
with tab_students:
|
|
|
|
| 109 |
q = st.text_input("Search students by name or email", "")
|
| 110 |
+
roster = _prefer_db(
|
| 111 |
+
"list_students_in_class",
|
| 112 |
+
lambda cid: api.list_students_in_class(cid),
|
| 113 |
+
[],
|
| 114 |
+
class_id, # positional
|
| 115 |
+
)
|
| 116 |
|
| 117 |
# simple filter
|
| 118 |
if q.strip():
|
| 119 |
ql = q.lower()
|
| 120 |
+
roster = [r for r in roster if ql in (r.get("name","").lower()) or ql in (r.get("email","").lower())]
|
| 121 |
|
| 122 |
st.caption(f"{len(roster)} Students Found")
|
| 123 |
|
|
|
|
| 125 |
st.info("No students in this class yet.")
|
| 126 |
else:
|
| 127 |
for s in roster:
|
| 128 |
+
st.subheader(f"👤 {s.get('name','(unknown)')}")
|
| 129 |
+
st.caption(s.get("email","—"))
|
| 130 |
joined = s.get("joined_at") or s.get("created_at")
|
| 131 |
+
st.caption(f"📅 Joined: {str(joined)[:10] if joined else '—'}")
|
| 132 |
+
st.progress(0.0) # placeholder bar
|
| 133 |
cols = st.columns(3)
|
| 134 |
+
level_slug = (s.get("level_slug") or s.get("level") or "beginner")
|
| 135 |
+
try:
|
| 136 |
+
level_label = level_slug.capitalize() if isinstance(level_slug, str) else str(level_slug)
|
| 137 |
+
except Exception:
|
| 138 |
+
level_label = "—"
|
| 139 |
+
cols[0].metric("⭐ Level", level_label)
|
| 140 |
+
cols[1].metric("📊 Avg Score", "—")
|
| 141 |
+
cols[2].metric("🔥 Streak", "—")
|
| 142 |
st.markdown("---")
|
| 143 |
|
| 144 |
# ============== Content tab ==============
|
| 145 |
with tab_content:
|
| 146 |
+
counts = _prefer_db(
|
| 147 |
+
"class_content_counts",
|
| 148 |
+
lambda cid: api.class_content_counts(cid),
|
| 149 |
+
{"lessons": 0, "quizzes": 0},
|
| 150 |
+
class_id, # positional
|
| 151 |
+
)
|
| 152 |
left, right = st.columns(2)
|
| 153 |
with left:
|
| 154 |
+
_metric_card("📖 Custom Lessons", str(counts.get("lessons", 0)), "Lessons created for this classroom")
|
| 155 |
with right:
|
| 156 |
+
_metric_card("🏆 Custom Quizzes", str(counts.get("quizzes", 0)), "Quizzes created for this classroom")
|
| 157 |
+
|
| 158 |
+
assigs = _prefer_db(
|
| 159 |
+
"list_class_assignments",
|
| 160 |
+
lambda cid: api.list_class_assignments(cid),
|
| 161 |
+
[],
|
| 162 |
+
class_id, # positional
|
| 163 |
+
)
|
| 164 |
if assigs:
|
| 165 |
st.markdown("#### Assigned items")
|
| 166 |
for a in assigs:
|
| 167 |
+
has_quiz = " + Quiz" if a.get("quiz_id") else ""
|
| 168 |
+
st.markdown(f"- **{a.get('title','Untitled')}** · {a.get('subject','—')} · {a.get('level','—')}{has_quiz}")
|
| 169 |
|
| 170 |
# ============== Analytics tab ==============
|
| 171 |
with tab_analytics:
|
| 172 |
+
stats = _prefer_db(
|
| 173 |
+
"class_analytics",
|
| 174 |
+
lambda cid: api.class_analytics(cid),
|
| 175 |
+
{"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0},
|
| 176 |
+
class_id, # positional
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
class_avg_pct = round(float(stats.get("class_avg", 0)) * 100) if stats.get("class_avg") is not None else 0
|
| 180 |
+
total_xp = stats.get("total_xp", 0)
|
| 181 |
+
lessons_completed = stats.get("lessons_completed", 0)
|
| 182 |
+
|
| 183 |
g1, g2, g3 = st.columns(3)
|
| 184 |
with g1:
|
| 185 |
+
_metric_card("📊 Class Average", f"{class_avg_pct}%", "Average quiz performance")
|
| 186 |
with g2:
|
| 187 |
+
_metric_card("🪙 Total XP", f"{total_xp}", "Combined XP earned")
|
| 188 |
with g3:
|
| 189 |
+
_metric_card("📘 Lessons Completed", f"{lessons_completed}", "Total lessons completed")
|
phase/Teacher_view/contentmanage.py
CHANGED
|
@@ -1,8 +1,13 @@
|
|
| 1 |
# phase/Teacher_view/contentmanage.py
|
| 2 |
import json
|
| 3 |
-
import
|
| 4 |
from datetime import datetime
|
|
|
|
| 5 |
from utils import db as dbapi
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
# ---------- small UI helpers ----------
|
| 8 |
def _pill(text):
|
|
@@ -16,36 +21,28 @@ def _progress(val: float):
|
|
| 16 |
</div>
|
| 17 |
"""
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
# ----------
|
| 21 |
-
def _generate_quiz_from_text(content: str, n_questions: int = 5):
|
| 22 |
"""
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
"""
|
| 27 |
-
system = (
|
| 28 |
-
"You are a Jamaican primary school financial literacy teacher. "
|
| 29 |
-
"Write clear multiple-choice questions (A-D) about the provided lesson content. "
|
| 30 |
-
"Keep language simple and age-appropriate. Only one correct answer per question."
|
| 31 |
-
)
|
| 32 |
-
user = (
|
| 33 |
-
f"Create {n_questions} MCQs strictly in this JSON format:\n"
|
| 34 |
-
"{\n"
|
| 35 |
-
' \"items\":[\n'
|
| 36 |
-
' {\"question\":\"...\", \"options\":[\"A\",\"B\",\"C\",\"D\"], \"answer_key\":\"A\"}\n'
|
| 37 |
-
" ]\n"
|
| 38 |
-
"}\n\n"
|
| 39 |
-
"Lesson content:\n"
|
| 40 |
-
f"{content}"
|
| 41 |
-
)
|
| 42 |
-
|
| 43 |
def _normalize(items):
|
| 44 |
out = []
|
| 45 |
for it in (items or [])[:n_questions]:
|
| 46 |
q = str(it.get("question", "")).strip()
|
| 47 |
opts = it.get("options", [])
|
| 48 |
-
if not q or not isinstance(opts, list)
|
| 49 |
continue
|
| 50 |
while len(opts) < 4:
|
| 51 |
opts.append("Option")
|
|
@@ -57,53 +54,108 @@ def _generate_quiz_from_text(content: str, n_questions: int = 5):
|
|
| 57 |
return out
|
| 58 |
|
| 59 |
try:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# 1) Preferred path: Responses API
|
| 64 |
-
try:
|
| 65 |
-
resp = client.responses.create(
|
| 66 |
-
model="gpt-4o-mini",
|
| 67 |
-
temperature=0.2,
|
| 68 |
-
response_format={"type": "json_object"},
|
| 69 |
-
input=[
|
| 70 |
-
{"role": "system", "content": [{"type": "text", "text": system}]},
|
| 71 |
-
{"role": "user", "content": [{"type": "text", "text": user}]},
|
| 72 |
-
],
|
| 73 |
-
)
|
| 74 |
-
raw = getattr(resp, "output_text", "") or ""
|
| 75 |
-
data = json.loads(raw)
|
| 76 |
-
return _normalize(data.get("items", []))
|
| 77 |
-
|
| 78 |
-
# 2) Fallback: Chat Completions
|
| 79 |
-
except Exception:
|
| 80 |
-
resp = client.chat.completions.create(
|
| 81 |
-
model="gpt-4o-mini",
|
| 82 |
-
temperature=0.2,
|
| 83 |
-
messages=[{"role":"system","content":system},{"role":"user","content":user}],
|
| 84 |
-
response_format={"type": "json_object"},
|
| 85 |
-
)
|
| 86 |
-
raw = resp.choices[0].message.content.strip()
|
| 87 |
-
data = json.loads(raw)
|
| 88 |
-
return _normalize(data.get("items", []))
|
| 89 |
-
|
| 90 |
except Exception as e:
|
| 91 |
with st.expander("Quiz generation error details"):
|
| 92 |
st.code(str(e))
|
| 93 |
-
st.warning("Quiz generation failed. Check
|
| 94 |
return []
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# ---------- Create panels ----------
|
| 97 |
def _create_lesson_panel(teacher_id: int):
|
| 98 |
st.markdown("### ✍️ Create New Lesson")
|
| 99 |
|
| 100 |
-
classes =
|
| 101 |
class_opts = {f"{c['name']} (code {c['code']})": c["class_id"] for c in classes} if classes else {}
|
| 102 |
|
| 103 |
if "cl_topic_count" not in st.session_state:
|
| 104 |
st.session_state.cl_topic_count = 2 # start with two topics
|
| 105 |
|
| 106 |
-
# UI-manipulation buttons OUTSIDE the form
|
| 107 |
cols_btn = st.columns([1,1,6])
|
| 108 |
with cols_btn[0]:
|
| 109 |
if st.button("➕ Add topic", type="secondary"):
|
|
@@ -144,16 +196,13 @@ def _create_lesson_panel(teacher_id: int):
|
|
| 144 |
|
| 145 |
st.markdown("#### Auto-generate a quiz from this lesson (optional)")
|
| 146 |
gen_quiz = st.checkbox("Generate a quiz from content", value=False)
|
| 147 |
-
q_count = st.slider("", 3, 10, 5)
|
| 148 |
-
|
| 149 |
-
# ONLY keep the main submit button inside the form
|
| 150 |
-
submitted = st.form_submit_button("Create lesson", type="primary")
|
| 151 |
|
|
|
|
| 152 |
|
| 153 |
if not submitted:
|
| 154 |
return
|
| 155 |
|
| 156 |
-
# build sections payload for DB
|
| 157 |
sections = []
|
| 158 |
for t, b in topic_rows:
|
| 159 |
if (t or b):
|
|
@@ -169,41 +218,47 @@ def _create_lesson_panel(teacher_id: int):
|
|
| 169 |
st.error("Please add a title and at least one topic.")
|
| 170 |
return
|
| 171 |
|
| 172 |
-
# create lesson
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
# assign to chosen classes (lesson only for now)
|
| 177 |
for label in assign_classes:
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
# auto-generate quiz
|
| 181 |
if gen_quiz:
|
| 182 |
text = "\n\n".join([s["title"] + "\n" + (s["content"] or "") for s in sections])
|
| 183 |
-
with st.spinner("Generating quiz..."):
|
| 184 |
-
items = _generate_quiz_from_text(text, n_questions=q_count)
|
| 185 |
if items:
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
| 190 |
|
| 191 |
st.session_state.show_create_lesson = False
|
| 192 |
st.rerun()
|
| 193 |
|
| 194 |
-
|
| 195 |
-
|
| 196 |
def _create_quiz_panel(teacher_id: int):
|
| 197 |
st.markdown("### 🏆 Create New Quiz")
|
| 198 |
|
| 199 |
-
|
| 200 |
-
lessons = dbapi.list_lessons_by_teacher(teacher_id)
|
| 201 |
lesson_map = {f"{L['title']} (#{L['lesson_id']})": L["lesson_id"] for L in lessons}
|
| 202 |
if not lesson_map:
|
| 203 |
st.info("Create a lesson first, then link a quiz to it.")
|
| 204 |
return
|
| 205 |
|
| 206 |
-
# dynamic questions
|
| 207 |
if "cq_q_count" not in st.session_state:
|
| 208 |
st.session_state.cq_q_count = 5
|
| 209 |
|
|
@@ -244,7 +299,6 @@ def _create_quiz_panel(teacher_id: int):
|
|
| 244 |
st.error("Please add a quiz title.")
|
| 245 |
return
|
| 246 |
|
| 247 |
-
# sanitize items
|
| 248 |
cleaned = []
|
| 249 |
for it in items:
|
| 250 |
q = (it["question"] or "").strip()
|
|
@@ -259,17 +313,23 @@ def _create_quiz_panel(teacher_id: int):
|
|
| 259 |
st.error("Add at least one valid question.")
|
| 260 |
return
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
| 268 |
|
| 269 |
def _edit_lesson_panel(teacher_id: int, lesson_id: int):
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
key_cnt = f"el_cnt_{lesson_id}"
|
| 275 |
if key_cnt not in st.session_state:
|
|
@@ -277,7 +337,6 @@ def _edit_lesson_panel(teacher_id: int, lesson_id: int):
|
|
| 277 |
|
| 278 |
st.markdown("### ✏️ Edit Lesson")
|
| 279 |
|
| 280 |
-
#Move UI-manipulation buttons
|
| 281 |
tools = st.columns([1,1,8])
|
| 282 |
with tools[0]:
|
| 283 |
if st.button("➕ Add section", key=f"el_add_{lesson_id}", use_container_width=True):
|
|
@@ -289,17 +348,16 @@ def _edit_lesson_panel(teacher_id: int, lesson_id: int):
|
|
| 289 |
st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
|
| 290 |
st.rerun()
|
| 291 |
|
| 292 |
-
# The form only has fields + a single submit (Save)
|
| 293 |
with st.form(f"edit_lesson_form_{lesson_id}", clear_on_submit=False):
|
| 294 |
c1, c2 = st.columns([2,1])
|
| 295 |
-
title = c1.text_input("Title", value=L
|
| 296 |
level = c2.selectbox(
|
| 297 |
"Level",
|
| 298 |
["beginner","intermediate","advanced"],
|
| 299 |
-
index=["beginner","intermediate","advanced"].index(L
|
| 300 |
)
|
| 301 |
description = st.text_area("Short description", value=L.get("description") or "")
|
| 302 |
-
subject = st.selectbox("Subject", ["numeracy","finance"], index=(0 if L
|
| 303 |
|
| 304 |
st.markdown("#### Sections")
|
| 305 |
edited_sections = []
|
|
@@ -313,7 +371,6 @@ def _edit_lesson_panel(teacher_id: int, lesson_id: int):
|
|
| 313 |
|
| 314 |
save = st.form_submit_button("💾 Save changes", type="primary", use_container_width=True)
|
| 315 |
|
| 316 |
-
# Cancel is a normal button outside the form
|
| 317 |
actions = st.columns([8,2])
|
| 318 |
with actions[1]:
|
| 319 |
cancel_clicked = st.button("✖ Cancel", key=f"el_cancel_{lesson_id}", type="secondary", use_container_width=True)
|
|
@@ -326,29 +383,37 @@ def _edit_lesson_panel(teacher_id: int, lesson_id: int):
|
|
| 326 |
if not save:
|
| 327 |
return
|
| 328 |
|
| 329 |
-
# validation + persist
|
| 330 |
if not title or not any((s["title"] or s["content"]).strip() for s in edited_sections):
|
| 331 |
st.error("Title and at least one non-empty section are required.")
|
| 332 |
return
|
| 333 |
|
| 334 |
-
ok =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
if ok:
|
| 336 |
st.success("✅ Lesson updated.")
|
| 337 |
st.session_state.show_edit_lesson = False
|
| 338 |
st.session_state.edit_lesson_id = None
|
| 339 |
st.rerun()
|
| 340 |
else:
|
| 341 |
-
st.error("Could not update this lesson. Check ownership or
|
| 342 |
-
|
| 343 |
|
| 344 |
def _edit_quiz_panel(teacher_id: int, quiz_id: int):
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
| 348 |
return
|
| 349 |
|
| 350 |
-
Q = data
|
| 351 |
raw_items = data.get("items", [])
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
def _dec(x):
|
| 354 |
if isinstance(x, str):
|
|
@@ -358,7 +423,6 @@ def _edit_quiz_panel(teacher_id: int, quiz_id: int):
|
|
| 358 |
return x
|
| 359 |
return x
|
| 360 |
|
| 361 |
-
# Normalize into simple dicts that the form can bind to
|
| 362 |
items = []
|
| 363 |
for it in raw_items:
|
| 364 |
opts = _dec(it.get("options")) or []
|
|
@@ -427,7 +491,6 @@ def _edit_quiz_panel(teacher_id: int, quiz_id: int):
|
|
| 427 |
if not save:
|
| 428 |
return
|
| 429 |
|
| 430 |
-
# sanitize
|
| 431 |
cleaned = []
|
| 432 |
for it in edited:
|
| 433 |
q = (it["question"] or "").strip()
|
|
@@ -439,7 +502,7 @@ def _edit_quiz_panel(teacher_id: int, quiz_id: int):
|
|
| 439 |
cleaned.append({
|
| 440 |
"question": q,
|
| 441 |
"options": opts[:4],
|
| 442 |
-
"answer_key": it["answer_key"],
|
| 443 |
"points": 1
|
| 444 |
})
|
| 445 |
|
|
@@ -447,15 +510,19 @@ def _edit_quiz_panel(teacher_id: int, quiz_id: int):
|
|
| 447 |
st.error("Title and at least one valid question are required.")
|
| 448 |
return
|
| 449 |
|
| 450 |
-
ok =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
if ok:
|
| 452 |
st.success("✅ Quiz updated.")
|
| 453 |
st.session_state.show_edit_quiz = False
|
| 454 |
st.session_state.edit_quiz_id = None
|
| 455 |
st.rerun()
|
| 456 |
else:
|
| 457 |
-
st.error("Could not update this quiz. Check ownership or
|
| 458 |
-
|
| 459 |
|
| 460 |
# ---------- Main page ----------
|
| 461 |
def show_page():
|
|
@@ -466,17 +533,17 @@ def show_page():
|
|
| 466 |
st.caption("Create and manage custom lessons and quizzes")
|
| 467 |
|
| 468 |
# preload lists
|
| 469 |
-
lessons =
|
| 470 |
-
quizzes =
|
| 471 |
|
| 472 |
-
# top action bar
|
| 473 |
a1, a2, _sp = st.columns([3,3,4])
|
| 474 |
if a1.button("➕ Create Lesson", use_container_width=True):
|
| 475 |
st.session_state.show_create_lesson = True
|
| 476 |
if a2.button("🏆 Create Quiz", use_container_width=True):
|
| 477 |
st.session_state.show_create_quiz = True
|
| 478 |
|
| 479 |
-
#
|
| 480 |
if st.session_state.get("show_create_lesson"):
|
| 481 |
with st.container(border=True):
|
| 482 |
_create_lesson_panel(teacher_id)
|
|
@@ -487,18 +554,17 @@ def show_page():
|
|
| 487 |
_create_quiz_panel(teacher_id)
|
| 488 |
st.markdown("---")
|
| 489 |
|
| 490 |
-
#
|
| 491 |
if st.session_state.get("show_edit_lesson") and st.session_state.get("edit_lesson_id"):
|
| 492 |
with st.container(border=True):
|
| 493 |
_edit_lesson_panel(teacher_id, st.session_state.edit_lesson_id)
|
| 494 |
st.markdown("---")
|
| 495 |
-
|
| 496 |
if st.session_state.get("show_edit_quiz") and st.session_state.get("edit_quiz_id"):
|
| 497 |
with st.container(border=True):
|
| 498 |
_edit_quiz_panel(teacher_id, st.session_state.edit_quiz_id)
|
| 499 |
st.markdown("---")
|
| 500 |
|
| 501 |
-
|
| 502 |
# Tabs
|
| 503 |
tab1, tab2 = st.tabs([f"Custom Lessons ({len(lessons)})", f"Custom Quizzes ({len(quizzes)})"])
|
| 504 |
|
|
@@ -507,14 +573,13 @@ def show_page():
|
|
| 507 |
if not lessons:
|
| 508 |
st.info("No lessons yet. Use **Create Lesson** above.")
|
| 509 |
else:
|
| 510 |
-
|
| 511 |
-
all_students = dbapi.list_all_students_for_teacher(teacher_id)
|
| 512 |
student_options = {f"{s['name']} · {s['email']}": s["user_id"] for s in all_students}
|
| 513 |
|
| 514 |
for L in lessons:
|
| 515 |
-
assignees =
|
| 516 |
-
assignee_names = [a
|
| 517 |
-
created = L
|
| 518 |
count = len(assignees)
|
| 519 |
|
| 520 |
with st.container(border=True):
|
|
@@ -523,8 +588,8 @@ def show_page():
|
|
| 523 |
st.markdown(f"### {L['title']}")
|
| 524 |
st.caption(L.get("description") or "")
|
| 525 |
st.markdown(
|
| 526 |
-
_pill(L
|
| 527 |
-
_pill(L
|
| 528 |
_pill(f"{count} student{'s' if count != 1 else ''} assigned") +
|
| 529 |
_pill(f"Created {created}"),
|
| 530 |
unsafe_allow_html=True
|
|
@@ -538,13 +603,13 @@ def show_page():
|
|
| 538 |
st.rerun()
|
| 539 |
with b2:
|
| 540 |
if st.button("Delete", key=f"del_{L['lesson_id']}"):
|
| 541 |
-
ok, msg =
|
| 542 |
if ok: st.success("Lesson deleted"); st.rerun()
|
| 543 |
-
else: st.error(msg)
|
| 544 |
|
| 545 |
st.markdown("**Assigned Students:**")
|
| 546 |
if assignee_names:
|
| 547 |
-
st.markdown(" ".join(_pill(n) for n in assignee_names), unsafe_allow_html=True)
|
| 548 |
else:
|
| 549 |
st.caption("No students assigned yet.")
|
| 550 |
|
|
@@ -554,15 +619,15 @@ def show_page():
|
|
| 554 |
st.info("No quizzes yet. Use **Create Quiz** above.")
|
| 555 |
else:
|
| 556 |
for Q in quizzes:
|
| 557 |
-
assignees =
|
| 558 |
-
created = Q
|
| 559 |
num_qs = int(Q.get("num_items", 0))
|
| 560 |
|
| 561 |
with st.container(border=True):
|
| 562 |
c1, c2 = st.columns([8,3])
|
| 563 |
with c1:
|
| 564 |
st.markdown(f"### {Q['title']}")
|
| 565 |
-
st.caption(f"Lesson: {Q
|
| 566 |
st.markdown(
|
| 567 |
_pill(f"{num_qs} question{'s' if num_qs != 1 else ''}") +
|
| 568 |
_pill(f"{len(assignees)} students assigned") +
|
|
@@ -578,25 +643,30 @@ def show_page():
|
|
| 578 |
st.rerun()
|
| 579 |
with b2:
|
| 580 |
if st.button("Delete", key=f"delq_{Q['quiz_id']}"):
|
| 581 |
-
ok, msg =
|
| 582 |
if ok: st.success("Quiz deleted"); st.rerun()
|
| 583 |
-
else: st.error(msg)
|
| 584 |
|
| 585 |
st.markdown("**Assigned Students:**")
|
| 586 |
if assignees:
|
| 587 |
-
st.markdown(" ".join(_pill(a
|
| 588 |
else:
|
| 589 |
st.caption("No students assigned yet.")
|
| 590 |
|
| 591 |
with st.expander("View questions", expanded=False):
|
| 592 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
items = data.get("items", []) if data else []
|
| 594 |
if not items:
|
| 595 |
st.info("No items found for this quiz.")
|
| 596 |
else:
|
| 597 |
labels = ["A","B","C","D"]
|
| 598 |
for i, it in enumerate(items, start=1):
|
| 599 |
-
# Handle JSON columns that may come back as strings
|
| 600 |
opts = it.get("options")
|
| 601 |
if isinstance(opts, str):
|
| 602 |
try:
|
|
@@ -615,4 +685,4 @@ def show_page():
|
|
| 615 |
st.write(f"{labels[j]}) {opt}")
|
| 616 |
ans_text = answer if isinstance(answer, str) else ",".join(answer or [])
|
| 617 |
st.caption(f"Answer: {ans_text}")
|
| 618 |
-
st.markdown("---")
|
|
|
|
| 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):
|
|
|
|
| 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")
|
|
|
|
| 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"):
|
|
|
|
| 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):
|
|
|
|
| 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 |
|
|
|
|
| 299 |
st.error("Please add a quiz title.")
|
| 300 |
return
|
| 301 |
|
|
|
|
| 302 |
cleaned = []
|
| 303 |
for it in items:
|
| 304 |
q = (it["question"] or "").strip()
|
|
|
|
| 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:
|
|
|
|
| 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):
|
|
|
|
| 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 = []
|
|
|
|
| 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)
|
|
|
|
| 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):
|
|
|
|
| 423 |
return x
|
| 424 |
return x
|
| 425 |
|
|
|
|
| 426 |
items = []
|
| 427 |
for it in raw_items:
|
| 428 |
opts = _dec(it.get("options")) or []
|
|
|
|
| 491 |
if not save:
|
| 492 |
return
|
| 493 |
|
|
|
|
| 494 |
cleaned = []
|
| 495 |
for it in edited:
|
| 496 |
q = (it["question"] or "").strip()
|
|
|
|
| 502 |
cleaned.append({
|
| 503 |
"question": q,
|
| 504 |
"options": opts[:4],
|
| 505 |
+
"answer_key": it["answer_key"],
|
| 506 |
"points": 1
|
| 507 |
})
|
| 508 |
|
|
|
|
| 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():
|
|
|
|
| 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)
|
|
|
|
| 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 |
|
|
|
|
| 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):
|
|
|
|
| 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
|
|
|
|
| 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 |
|
|
|
|
| 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") +
|
|
|
|
| 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:
|
|
|
|
| 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
CHANGED
|
@@ -1,36 +1,55 @@
|
|
| 1 |
# phase/Teacher_view/studentlist.py
|
|
|
|
| 2 |
import streamlit as st
|
| 3 |
from utils import db as dbapi
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
# ---------- tiny helpers ----------
|
| 6 |
def _avatar(name: str) -> str:
|
| 7 |
-
|
| 8 |
return "🧑🎓" if hash(name) % 2 else "👩🎓"
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
def _report_text(r, level, avg_pct):
|
| 11 |
return (
|
| 12 |
"STUDENT PROGRESS REPORT\n"
|
| 13 |
"======================\n"
|
| 14 |
-
f"Student: {r
|
| 15 |
-
f"Email: {r
|
| 16 |
-
f"Joined: {str(r
|
| 17 |
"PROGRESS OVERVIEW\n"
|
| 18 |
"-----------------\n"
|
| 19 |
-
f"Lessons Completed: {int(r
|
| 20 |
f"Average Quiz Score: {avg_pct}%\n"
|
| 21 |
-
f"Total XP: {int(r
|
| 22 |
f"Current Level: {level}\n"
|
| 23 |
-
f"Study Streak: {int(r
|
| 24 |
)
|
| 25 |
|
| 26 |
-
def _level_from_xp(total_xp: int) -> int:
|
| 27 |
-
try:
|
| 28 |
-
xp = int(total_xp or 0)
|
| 29 |
-
except Exception:
|
| 30 |
-
xp = 0
|
| 31 |
-
return 1 + xp // 500
|
| 32 |
-
|
| 33 |
-
|
| 34 |
ROW_CSS = """
|
| 35 |
<style>
|
| 36 |
.sm-chip{display:inline-block;padding:4px 10px;border-radius:999px;background:#eef7f1;color:#0b8f5d;font-weight:600;font-size:.80rem;margin-left:8px}
|
|
@@ -46,16 +65,56 @@ ROW_CSS = """
|
|
| 46 |
</style>
|
| 47 |
"""
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# ---------- page ----------
|
| 50 |
def show_page():
|
| 51 |
st.title("🎓 Student Management")
|
| 52 |
st.caption("Monitor and manage your students' progress")
|
| 53 |
st.markdown(ROW_CSS, unsafe_allow_html=True)
|
| 54 |
|
| 55 |
-
teacher = st.session_state.user
|
|
|
|
|
|
|
|
|
|
| 56 |
teacher_id = teacher["user_id"]
|
| 57 |
|
| 58 |
-
classes =
|
| 59 |
if not classes:
|
| 60 |
st.info("No classes yet. Create one in Classroom Management.")
|
| 61 |
return
|
|
@@ -65,14 +124,18 @@ def show_page():
|
|
| 65 |
"Choose a class",
|
| 66 |
list(range(len(classes))),
|
| 67 |
index=0,
|
| 68 |
-
format_func=lambda i: f"{classes[i]
|
| 69 |
)
|
| 70 |
selected = classes[idx]
|
| 71 |
-
class_id = selected
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
# get students before drawing chips
|
| 75 |
-
rows =
|
| 76 |
|
| 77 |
# code + student chip row
|
| 78 |
chip1, chip2 = st.columns([1, 1])
|
|
@@ -86,7 +149,7 @@ def show_page():
|
|
| 86 |
f'<div class="sm-chip">👥 {len(rows)} Students</div>',
|
| 87 |
unsafe_allow_html=True
|
| 88 |
)
|
| 89 |
-
|
| 90 |
st.markdown("---")
|
| 91 |
|
| 92 |
# search line
|
|
@@ -96,19 +159,23 @@ def show_page():
|
|
| 96 |
).strip().lower()
|
| 97 |
|
| 98 |
if query:
|
| 99 |
-
rows = [
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
# student rows
|
| 102 |
for r in rows:
|
| 103 |
-
name = r
|
| 104 |
-
email = r
|
| 105 |
-
joined = str(r
|
| 106 |
-
total_xp = int(r
|
| 107 |
level = _level_from_xp(total_xp)
|
| 108 |
-
lessons_completed = int(r
|
| 109 |
-
total_assigned = int(r
|
| 110 |
-
avg_pct =
|
| 111 |
-
streak = int(r
|
|
|
|
| 112 |
|
| 113 |
with st.container():
|
| 114 |
st.markdown('<div class="sm-row">', unsafe_allow_html=True)
|
|
@@ -140,12 +207,17 @@ def show_page():
|
|
| 140 |
d1, d2, spacer = st.columns([2, 1.3, 5])
|
| 141 |
with d1:
|
| 142 |
with st.popover("👁️ View Details"):
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
| 145 |
if items:
|
| 146 |
for it in items[:25]:
|
| 147 |
-
tag = " + Quiz" if it
|
| 148 |
-
st.markdown(
|
|
|
|
|
|
|
|
|
|
| 149 |
else:
|
| 150 |
st.info("No assignments yet.")
|
| 151 |
with d2:
|
|
@@ -153,9 +225,9 @@ def show_page():
|
|
| 153 |
st.download_button(
|
| 154 |
"⬇️ Export",
|
| 155 |
data=rep,
|
| 156 |
-
file_name=f"{name.replace(' ','_')}_report.txt",
|
| 157 |
mime="text/plain",
|
| 158 |
-
key=f"dl_{
|
| 159 |
)
|
| 160 |
|
| 161 |
-
st.markdown('</div>', unsafe_allow_html=True)
|
|
|
|
| 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}
|
|
|
|
| 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
|
|
|
|
| 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])
|
|
|
|
| 149 |
f'<div class="sm-chip">👥 {len(rows)} Students</div>',
|
| 150 |
unsafe_allow_html=True
|
| 151 |
)
|
| 152 |
+
|
| 153 |
st.markdown("---")
|
| 154 |
|
| 155 |
# search line
|
|
|
|
| 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)
|
|
|
|
| 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:
|
|
|
|
| 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)
|
utils/api.py
CHANGED
|
@@ -87,6 +87,8 @@ def health():
|
|
| 87 |
return {"ok": False}
|
| 88 |
|
| 89 |
#---helpers
|
|
|
|
|
|
|
| 90 |
def user_stats(student_id: int):
|
| 91 |
return _req("GET", f"/students/{student_id}/stats").json()
|
| 92 |
def list_assignments_for_student(student_id: int):
|
|
@@ -96,6 +98,167 @@ def student_quiz_average(student_id: int):
|
|
| 96 |
def recent_lessons_for_student(student_id: int, limit: int = 5):
|
| 97 |
return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json()
|
| 98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
# ---- Legacy agent endpoints (keep) ----
|
| 101 |
def start_agent(student_id: int, lesson_id: int, level_slug: str):
|
|
|
|
| 87 |
return {"ok": False}
|
| 88 |
|
| 89 |
#---helpers
|
| 90 |
+
|
| 91 |
+
#--helpers for student_db.py
|
| 92 |
def user_stats(student_id: int):
|
| 93 |
return _req("GET", f"/students/{student_id}/stats").json()
|
| 94 |
def list_assignments_for_student(student_id: int):
|
|
|
|
| 98 |
def recent_lessons_for_student(student_id: int, limit: int = 5):
|
| 99 |
return _req("GET", f"/students/{student_id}/recent", params={"limit": limit}).json()
|
| 100 |
|
| 101 |
+
# --- Teacher endpoints (backend Space) ---
|
| 102 |
+
def create_class(teacher_id: int, name: str):
|
| 103 |
+
return _json_or_raise(_req("POST", f"/teachers/{teacher_id}/classes",
|
| 104 |
+
json={"name": name}))
|
| 105 |
+
|
| 106 |
+
def teacher_tiles(teacher_id: int):
|
| 107 |
+
return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/tiles"))
|
| 108 |
+
|
| 109 |
+
def list_classes_by_teacher(teacher_id: int):
|
| 110 |
+
return _json_or_raise(_req("GET", f"/teachers/{teacher_id}/classes"))
|
| 111 |
+
|
| 112 |
+
def class_student_metrics(class_id: int):
|
| 113 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/student_metrics"))
|
| 114 |
+
|
| 115 |
+
def class_weekly_activity(class_id: int):
|
| 116 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/weekly_activity"))
|
| 117 |
+
|
| 118 |
+
def class_progress_overview(class_id: int):
|
| 119 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/progress_overview"))
|
| 120 |
+
|
| 121 |
+
def class_recent_activity(class_id: int, limit=6, days=30):
|
| 122 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/recent_activity",
|
| 123 |
+
params={"limit": limit, "days": days}))
|
| 124 |
+
|
| 125 |
+
def list_students_in_class(class_id: int):
|
| 126 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/students"))
|
| 127 |
+
|
| 128 |
+
# Optional if you want to compute levels server-side
|
| 129 |
+
def level_from_xp(xp: int):
|
| 130 |
+
return _json_or_raise(_req("GET", "/levels/from_xp", params={"xp": xp}))["level"]
|
| 131 |
+
|
| 132 |
+
#--teacherlink.py helpers
|
| 133 |
+
def join_class_by_code(student_id: int, code: str):
|
| 134 |
+
d = _json_or_raise(_req("POST", f"/students/{student_id}/classes/join", json={"code": code}))
|
| 135 |
+
# backend may return {"class_id": ...} or full class object; both are fine
|
| 136 |
+
return d.get("class_id", d)
|
| 137 |
+
|
| 138 |
+
def list_classes_for_student(student_id: int):
|
| 139 |
+
return _json_or_raise(_req("GET", f"/students/{student_id}/classes"))
|
| 140 |
+
|
| 141 |
+
def class_content_counts(class_id: int):
|
| 142 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/counts"))
|
| 143 |
+
|
| 144 |
+
def student_class_progress(student_id: int, class_id: int):
|
| 145 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/progress"))
|
| 146 |
+
|
| 147 |
+
def leave_class(student_id: int, class_id: int):
|
| 148 |
+
# could also be DELETE /classes/{class_id}/students/{student_id}
|
| 149 |
+
_json_or_raise(_req("POST", f"/classes/{class_id}/leave", json={"student_id": student_id}))
|
| 150 |
+
return True
|
| 151 |
+
|
| 152 |
+
def student_assignments_for_class(student_id: int, class_id: int):
|
| 153 |
+
return _json_or_raise(_req("GET", f"/classes/{class_id}/students/{student_id}/assignments"))
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# ---- Classes / Teacher endpoints ----
|
| 157 |
+
def create_class(teacher_id: int, name: str):
|
| 158 |
+
return _req("POST", f"/teachers/{teacher_id}/classes", json={"name": name}).json()
|
| 159 |
+
|
| 160 |
+
def list_classes_by_teacher(teacher_id: int):
|
| 161 |
+
return _req("GET", f"/teachers/{teacher_id}/classes").json()
|
| 162 |
+
|
| 163 |
+
def list_students_in_class(class_id: int):
|
| 164 |
+
return _req("GET", f"/classes/{class_id}/students").json()
|
| 165 |
+
|
| 166 |
+
def class_content_counts(class_id: int):
|
| 167 |
+
return _req("GET", f"/classes/{class_id}/content_counts").json()
|
| 168 |
+
|
| 169 |
+
def list_class_assignments(class_id: int):
|
| 170 |
+
return _req("GET", f"/classes/{class_id}/assignments").json()
|
| 171 |
+
|
| 172 |
+
def class_analytics(class_id: int):
|
| 173 |
+
return _req("GET", f"/classes/{class_id}/analytics").json()
|
| 174 |
+
|
| 175 |
+
#--contentmanage.py helpers
|
| 176 |
+
|
| 177 |
+
# ---------- Teacher/content management endpoints (backend Space) ----------
|
| 178 |
+
def list_classes_by_teacher(teacher_id: int):
|
| 179 |
+
return _req("GET", f"/teachers/{teacher_id}/classes").json()
|
| 180 |
+
|
| 181 |
+
def list_all_students_for_teacher(teacher_id: int):
|
| 182 |
+
return _req("GET", f"/teachers/{teacher_id}/students").json()
|
| 183 |
+
|
| 184 |
+
def list_lessons_by_teacher(teacher_id: int):
|
| 185 |
+
return _req("GET", f"/teachers/{teacher_id}/lessons").json()
|
| 186 |
+
|
| 187 |
+
def list_quizzes_by_teacher(teacher_id: int):
|
| 188 |
+
return _req("GET", f"/teachers/{teacher_id}/quizzes").json()
|
| 189 |
+
|
| 190 |
+
def create_lesson(teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
|
| 191 |
+
d = _req("POST", "/lessons", json={
|
| 192 |
+
"teacher_id": teacher_id, "title": title, "description": description,
|
| 193 |
+
"subject": subject, "level": level, "sections": sections
|
| 194 |
+
}).json()
|
| 195 |
+
return d["lesson_id"]
|
| 196 |
+
|
| 197 |
+
def update_lesson(lesson_id: int, teacher_id: int, title: str, description: str, subject: str, level: str, sections: list[dict]):
|
| 198 |
+
d = _req("PUT", f"/lessons/{lesson_id}", json={
|
| 199 |
+
"teacher_id": teacher_id, "title": title, "description": description,
|
| 200 |
+
"subject": subject, "level": level, "sections": sections
|
| 201 |
+
}).json()
|
| 202 |
+
return bool(d.get("ok", True))
|
| 203 |
+
|
| 204 |
+
def delete_lesson(lesson_id: int, teacher_id: int):
|
| 205 |
+
d = _req("DELETE", f"/lessons/{lesson_id}", json={"teacher_id": teacher_id}).json()
|
| 206 |
+
return bool(d.get("ok", True)), d.get("message", "")
|
| 207 |
+
|
| 208 |
+
def get_lesson(lesson_id: int):
|
| 209 |
+
return _req("GET", f"/lessons/{lesson_id}").json() # {"lesson":{...}, "sections":[...]}
|
| 210 |
+
|
| 211 |
+
def create_quiz(lesson_id: int, title: str, items: list[dict], settings: dict):
|
| 212 |
+
d = _req("POST", "/quizzes", json={"lesson_id": lesson_id, "title": title, "items": items, "settings": settings}).json()
|
| 213 |
+
return d["quiz_id"]
|
| 214 |
+
|
| 215 |
+
def update_quiz(quiz_id: int, teacher_id: int, title: str, items: list[dict], settings: dict):
|
| 216 |
+
d = _req("PUT", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id, "title": title, "items": items, "settings": settings}).json()
|
| 217 |
+
return bool(d.get("ok", True))
|
| 218 |
+
|
| 219 |
+
def delete_quiz(quiz_id: int, teacher_id: int):
|
| 220 |
+
d = _req("DELETE", f"/quizzes/{quiz_id}", json={"teacher_id": teacher_id}).json()
|
| 221 |
+
return bool(d.get("ok", True)), d.get("message", "")
|
| 222 |
+
|
| 223 |
+
def list_assigned_students_for_lesson(lesson_id: int):
|
| 224 |
+
return _req("GET", f"/lessons/{lesson_id}/assignees").json()
|
| 225 |
+
|
| 226 |
+
def list_assigned_students_for_quiz(quiz_id: int):
|
| 227 |
+
return _req("GET", f"/quizzes/{quiz_id}/assignees").json()
|
| 228 |
+
|
| 229 |
+
def assign_to_class(lesson_id: int | None, quiz_id: int | None, class_id: int, teacher_id: int):
|
| 230 |
+
d = _req("POST", "/assignments", json={
|
| 231 |
+
"lesson_id": lesson_id, "quiz_id": quiz_id, "class_id": class_id, "teacher_id": teacher_id
|
| 232 |
+
}).json()
|
| 233 |
+
return bool(d.get("ok", True))
|
| 234 |
+
|
| 235 |
+
# ---------- LLM-based quiz generation (backend uses GEN_MODEL) ----------
|
| 236 |
+
def generate_quiz_from_text(content: str, n_questions: int = 5, subject: str = "finance", level: str = "beginner"):
|
| 237 |
+
"""
|
| 238 |
+
Backend should read GEN_MODEL from env and use your chosen model (llama-3.1-8b-instruct).
|
| 239 |
+
Return shape: {"items":[{"question":...,"options":[...],"answer_key":"A"}]}
|
| 240 |
+
"""
|
| 241 |
+
return _req("POST", "/quiz/generate", json={
|
| 242 |
+
"content": content, "n_questions": n_questions, "subject": subject, "level": level
|
| 243 |
+
}).json()
|
| 244 |
+
|
| 245 |
+
#-- studentlist helpers
|
| 246 |
+
|
| 247 |
+
def list_classes_by_teacher(teacher_id: int):
|
| 248 |
+
return _req("GET", f"/teachers/{teacher_id}/classes").json()
|
| 249 |
+
|
| 250 |
+
def get_class(class_id: int):
|
| 251 |
+
return _req("GET", f"/classes/{class_id}").json()
|
| 252 |
+
|
| 253 |
+
def class_student_metrics(class_id: int):
|
| 254 |
+
# expected to return list of rows with fields used in the UI
|
| 255 |
+
return _req("GET", f"/classes/{class_id}/students").json()
|
| 256 |
+
|
| 257 |
+
def list_assignments_for_student(student_id: int):
|
| 258 |
+
return _req("GET", f"/students/{student_id}/assignments").json()
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
|
| 262 |
|
| 263 |
# ---- Legacy agent endpoints (keep) ----
|
| 264 |
def start_agent(student_id: int, lesson_id: int, level_slug: str):
|