| |
| import os |
| import streamlit as st |
| from utils import db as dbapi |
| import utils.api as api |
|
|
| |
| USE_LOCAL_DB = os.getenv("DISABLE_DB", "1") != "1" |
|
|
| |
| def _avatar(name: str) -> str: |
| return "π§βπ" if hash(name) % 2 else "π©βπ" |
|
|
| def _avg_pct_from_row(r) -> int: |
| """ |
| Accepts either: |
| - r['avg_pct'] in [0, 100] |
| - r['avg_score'] in [0, 1] or [0, 100] |
| Returns an int 0..100. |
| """ |
| v = r.get("avg_pct", r.get("avg_score", 0)) or 0 |
| try: |
| f = float(v) |
| if f <= 1.0: |
| f *= 100.0 |
| return max(0, min(100, int(round(f)))) |
| except Exception: |
| return 0 |
|
|
| def _level_from_xp(total_xp: int) -> int: |
| try: |
| xp = int(total_xp or 0) |
| except Exception: |
| xp = 0 |
| return 1 + xp // 500 |
|
|
| def _report_text(r, level, avg_pct): |
| return ( |
| "STUDENT PROGRESS REPORT\n" |
| "======================\n" |
| f"Student: {r.get('name','')}\n" |
| f"Email: {r.get('email','')}\n" |
| f"Joined: {str(r.get('joined_at',''))[:10]}\n\n" |
| "PROGRESS OVERVIEW\n" |
| "-----------------\n" |
| f"Lessons Completed: {int(r.get('lessons_completed') or 0)}/{int(r.get('total_assigned_lessons') or 0)}\n" |
| f"Average Quiz Score: {avg_pct}%\n" |
| f"Total XP: {int(r.get('total_xp') or 0)}\n" |
| f"Current Level: {level}\n" |
| f"Study Streak: {int(r.get('streak_days') or 0)} days\n" |
| ) |
|
|
| ROW_CSS = """ |
| <style> |
| .sm-chip{display:inline-block;padding:4px 10px;border-radius:999px;background:#eef7f1;color:#0b8f5d;font-weight:600;font-size:.80rem;margin-left:8px} |
| .sm-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid #e6e6e6;border-radius:8px;background:#fff} |
| .sm-row{border:1px solid #eee;border-radius:12px;padding:16px 16px;margin:10px 0;background:#fff} |
| .sm-row:hover{box-shadow:0 2px 10px rgba(0,0,0,.04)} |
| .sm-right{display:flex;gap:16px;align-items:center;justify-content:flex-end} |
| .sm-metric{min-width:90px;text-align:right} |
| .sm-metric .label{color:#777;font-size:.75rem} |
| .sm-metric .value{font-weight:700;font-size:1.1rem} |
| .sm-name{font-size:1.05rem;font-weight:700} |
| .sm-sub{color:#6c6c6c;font-size:.85rem} |
| </style> |
| """ |
|
|
| |
| @st.cache_data(show_spinner=False, ttl=30) |
| def _list_classes_by_teacher(teacher_id: int): |
| if USE_LOCAL_DB and hasattr(dbapi, "list_classes_by_teacher"): |
| return dbapi.list_classes_by_teacher(teacher_id) or [] |
| try: |
| return api.list_classes_by_teacher(teacher_id) or [] |
| except Exception: |
| return [] |
|
|
| @st.cache_data(show_spinner=False, ttl=30) |
| def _get_class(class_id: int): |
| if USE_LOCAL_DB and hasattr(dbapi, "get_class"): |
| return dbapi.get_class(class_id) or {} |
| try: |
| return api.get_class(class_id) or {} |
| except Exception: |
| return {} |
|
|
| @st.cache_data(show_spinner=False, ttl=30) |
| def _class_student_metrics(class_id: int): |
| if USE_LOCAL_DB and hasattr(dbapi, "class_student_metrics"): |
| return dbapi.class_student_metrics(class_id) or [] |
| try: |
| return api.class_student_metrics(class_id) or [] |
| except Exception: |
| return [] |
|
|
| @st.cache_data(show_spinner=False, ttl=30) |
| def _list_assignments_for_student(student_id: int): |
| if USE_LOCAL_DB and hasattr(dbapi, "list_assignments_for_student"): |
| return dbapi.list_assignments_for_student(student_id) or [] |
| try: |
| return api.list_assignments_for_student(student_id) or [] |
| except Exception: |
| return [] |
|
|
| |
| def show_page(): |
| st.title("π Student Management") |
| st.caption("Monitor and manage your students' progress") |
| st.markdown(ROW_CSS, unsafe_allow_html=True) |
|
|
| teacher = st.session_state.get("user") |
| if not teacher: |
| st.error("Please log in.") |
| return |
| teacher_id = teacher["user_id"] |
|
|
| classes = _list_classes_by_teacher(teacher_id) |
| if not classes: |
| st.info("No classes yet. Create one in Classroom Management.") |
| return |
|
|
| |
| idx = st.selectbox( |
| "Choose a class", |
| list(range(len(classes))), |
| index=0, |
| format_func=lambda i: f"{classes[i].get('name','(unnamed)')}" |
| ) |
| selected = classes[idx] |
| class_id = selected.get("class_id") or selected.get("id") |
| if class_id is None: |
| st.error("Selected class is missing an ID.") |
| return |
|
|
| code_row = _get_class(class_id) |
|
|
| |
| rows = _class_student_metrics(class_id) |
|
|
| |
| chip1, chip2 = st.columns([1, 1]) |
| with chip1: |
| st.markdown( |
| f'<div class="sm-chip">Code: {code_row.get("code","")}</div>', |
| unsafe_allow_html=True |
| ) |
| with chip2: |
| st.markdown( |
| f'<div class="sm-chip">π₯ {len(rows)} Students</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| st.markdown("---") |
|
|
| |
| query = st.text_input( |
| "Search students by name or email", |
| placeholder="Type a name or email..." |
| ).strip().lower() |
|
|
| if query: |
| rows = [ |
| r for r in rows |
| if query in (r.get("name","").lower()) or query in (r.get("email","").lower()) |
| ] |
|
|
| |
| for r in rows: |
| name = r.get("name", "Unknown") |
| email = r.get("email", "") |
| joined = str(r.get("joined_at", ""))[:10] |
| total_xp = int(r.get("total_xp") or 0) |
| level = _level_from_xp(total_xp) |
| lessons_completed = int(r.get("lessons_completed") or 0) |
| total_assigned = int(r.get("total_assigned_lessons") or 0) |
| avg_pct = _avg_pct_from_row(r) |
| streak = int(r.get("streak_days") or 0) |
| student_id = r.get("student_id") or r.get("id") |
|
|
| with st.container(): |
| st.markdown('<div class="sm-row">', unsafe_allow_html=True) |
|
|
| |
| a, b, c = st.columns([0.7, 4, 3]) |
| with a: |
| st.markdown(f"### {_avatar(name)}") |
| with b: |
| st.markdown(f'<div class="sm-name">{name}</div>', unsafe_allow_html=True) |
| st.markdown(f'<div class="sm-sub">{email} Β· Joined {joined}</div>', unsafe_allow_html=True) |
| with c: |
| st.markdown( |
| '<div class="sm-right">' |
| f'<div class="sm-metric"><div class="value">{level}</div><div class="label">Level</div></div>' |
| f'<div class="sm-metric"><div class="value">{avg_pct}%</div><div class="label">Avg Score</div></div>' |
| f'<div class="sm-metric"><div class="value">{streak}</div><div class="label">Streak</div></div>' |
| "</div>", |
| unsafe_allow_html=True |
| ) |
|
|
| |
| st.caption("Overall Progress") |
| frac = (lessons_completed / total_assigned) if total_assigned > 0 else 0.0 |
| st.progress(min(1.0, frac)) |
| st.caption(f"{lessons_completed}/{total_assigned} lessons") |
|
|
| |
| d1, d2, spacer = st.columns([2, 1.3, 5]) |
| with d1: |
| with st.popover("ποΈ View Details"): |
| if student_id is not None: |
| items = _list_assignments_for_student(int(student_id)) |
| else: |
| items = [] |
| if items: |
| for it in items[:25]: |
| tag = " + Quiz" if it.get("quiz_id") else "" |
| st.markdown( |
| f"- **{it.get('title','Untitled')}** Β· {it.get('subject','General')} Β· " |
| f"{it.get('level','')} {tag} Β· Status: {it.get('status','unknown')}" |
| ) |
| else: |
| st.info("No assignments yet.") |
| with d2: |
| rep = _report_text(r, level, avg_pct) |
| st.download_button( |
| "β¬οΈ Export", |
| data=rep, |
| file_name=f"{str(name).replace(' ','_')}_report.txt", |
| mime="text/plain", |
| key=f"dl_{student_id or name}" |
| ) |
|
|
| st.markdown('</div>', unsafe_allow_html=True) |
|
|