| |
| import os |
| import json |
| import certifi |
| from contextlib import contextmanager |
| from datetime import date, timedelta |
|
|
| |
| import bcrypt |
|
|
| |
| DISABLE_DB = os.getenv("DISABLE_DB", "1") == "1" |
|
|
| |
| MYSQL_AVAILABLE = False |
| if not DISABLE_DB: |
| try: |
| import mysql.connector |
| from mysql.connector import Error |
| MYSQL_AVAILABLE = True |
| except Exception: |
| MYSQL_AVAILABLE = False |
|
|
| def _db_disabled_error(): |
| raise RuntimeError( |
| "Database access is disabled in this frontend (DISABLE_DB=1). " |
| "Route calls through your backend Space instead." |
| ) |
|
|
| def get_db_connection(): |
| if DISABLE_DB or not MYSQL_AVAILABLE: |
| _db_disabled_error() |
| ssl_enabled = os.getenv("TIDB_ENABLE_SSL", "false").lower() == "true" |
| ssl_ca = certifi.where() if ssl_enabled else None |
| return mysql.connector.connect( |
| host=os.getenv("TIDB_HOST"), |
| port=int(os.getenv("TIDB_PORT", 4000)), |
| user=os.getenv("TIDB_USER"), |
| password=os.getenv("TIDB_PASSWORD"), |
| database=os.getenv("TIDB_DATABASE", "fin_ed_agentic"), |
| ssl_ca=ssl_ca, |
| ssl_verify_cert=ssl_enabled, |
| autocommit=True, |
| ) |
|
|
| @contextmanager |
| def cursor(dict_rows=True): |
| if DISABLE_DB or not MYSQL_AVAILABLE: |
| _db_disabled_error() |
| conn = get_db_connection() |
| try: |
| cur = conn.cursor(dictionary=dict_rows) |
| yield cur |
| conn.commit() |
| finally: |
| cur.close() |
| conn.close() |
|
|
|
|
|
|
| |
| import bcrypt |
|
|
| |
| COUNTRY_SLUG = { |
| "Jamaica": "jamaica", "USA": "usa", "UK": "uk", |
| "India": "india", "Canada": "canada", "Other": "other", "N/A": "na" |
| } |
| LEVEL_SLUG = { |
| "Beginner": "beginner", "Intermediate": "intermediate", "Advanced": "advanced", "N/A": "na" |
| } |
| ROLE_SLUG = {"Student": "student", "Teacher": "teacher"} |
|
|
| def _slug(s: str) -> str: |
| return (s or "").strip().lower() |
|
|
| def hash_password(plain: str) -> bytes: |
| return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()) |
|
|
| def verify_password(plain: str, hashed: bytes | None) -> bool: |
| if not plain or not hashed: |
| return False |
| try: |
| return bcrypt.checkpw(plain.encode("utf-8"), hashed) |
| except Exception: |
| return False |
|
|
| def _ensure_na_slugs(): |
| """ |
| Make sure 'na' exists in countries/levels for teacher rows. |
| Harmless if already present. |
| """ |
| with cursor() as cur: |
| cur.execute("INSERT IGNORE INTO countries(slug,label) VALUES('na','N/A')") |
| cur.execute("INSERT IGNORE INTO levels(slug,label) VALUES('na','N/A')") |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| @contextmanager |
| def cursor(dict_rows=True): |
| conn = get_db_connection() |
| try: |
| cur = conn.cursor(dictionary=dict_rows) |
| yield cur |
| conn.commit() |
| finally: |
| cur.close() |
| conn.close() |
|
|
| |
| def create_user(name:str, email:str, country:str, level:str, role:str): |
| |
| slug = lambda s: s.strip().lower() |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO users(name,email,country_slug,level_slug,role_slug) |
| VALUES (%s,%s,%s,%s,%s) |
| """, (name, email.strip().lower(), slug(country), slug(level), slug(role))) |
| return True |
|
|
| |
| def create_student(*, name:str, email:str, password:str, level_label:str, country_label:str) -> bool: |
| """ |
| level_label/country_label are UI labels (e.g., 'Beginner', 'Jamaica'). |
| """ |
| level_slug = LEVEL_SLUG.get(level_label, _slug(level_label)) |
| country_slug = COUNTRY_SLUG.get(country_label, _slug(country_label)) |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO users (name,email,password_hash,title,country_slug,level_slug,role_slug) |
| VALUES (%s,%s,%s,NULL,%s,%s,'student') |
| """, (name.strip(), email.strip().lower(), hash_password(password), country_slug, level_slug)) |
| return True |
|
|
| def create_teacher(*, title:str, name:str, email:str, password:str) -> bool: |
| """ |
| Teachers do not provide level/country; we store 'na' for both. |
| """ |
| _ensure_na_slugs() |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO users (title,name,email,password_hash,country_slug,level_slug,role_slug) |
| VALUES (%s,%s,%s,%s,'na','na','teacher') |
| """, (title.strip(), name.strip(), email.strip().lower(), hash_password(password))) |
| return True |
|
|
|
|
| def get_user_by_email(email:str): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| u.user_id, u.title, u.name, u.email, u.password_hash, |
| u.country_slug, c.label AS country, |
| u.level_slug, l.label AS level, |
| u.role_slug, r.label AS role |
| FROM users u |
| JOIN countries c ON c.slug = u.country_slug |
| JOIN levels l ON l.slug = u.level_slug |
| JOIN roles r ON r.slug = u.role_slug |
| WHERE u.email=%s |
| LIMIT 1 |
| """, (email.strip().lower(),)) |
| u = cur.fetchone() |
| if not u: |
| return None |
| |
| u["role"] = "Teacher" if u["role_slug"] == "teacher" else "Student" |
| return u |
|
|
| def check_password(email: str, plain_password: str) -> dict | None: |
| """ |
| Returns the user dict if password is correct, else None. |
| """ |
| user = get_user_by_email(email) |
| if not user: |
| return None |
| if verify_password(plain_password, user.get("password_hash")): |
| return user |
| return None |
|
|
|
|
| |
| import random, string |
| def _code(): |
| return "".join(random.choices(string.ascii_uppercase + string.digits, k=6)) |
|
|
| def create_class(teacher_id:int, name:str): |
| |
| for _ in range(20): |
| code = _code() |
| with cursor() as cur: |
| cur.execute("SELECT 1 FROM classes WHERE code=%s", (code,)) |
| if not cur.fetchone(): |
| cur.execute("INSERT INTO classes(teacher_id,name,code) VALUES(%s,%s,%s)", |
| (teacher_id, name, code)) |
| cur.execute("SELECT LAST_INSERT_ID() AS id") |
| cid = cur.fetchone()["id"] |
| return {"class_id": cid, "code": code} |
| raise RuntimeError("Could not generate unique class code") |
|
|
|
|
|
|
| def join_class_by_code(student_id:int, code:str): |
| with cursor() as cur: |
| cur.execute("SELECT class_id FROM classes WHERE code=%s", (code.strip().upper(),)) |
| row = cur.fetchone() |
| if not row: |
| raise ValueError("Invalid class code") |
| cur.execute("INSERT IGNORE INTO class_students(class_id,student_id) VALUES(%s,%s)", |
| (row["class_id"], student_id)) |
| return row["class_id"] |
|
|
| def list_students_in_class(class_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| u.user_id, u.name, u.email, u.level_slug, |
| cs.joined_at, -- <- show true join date |
| u.created_at |
| FROM class_students cs |
| JOIN users u ON u.user_id = cs.student_id |
| WHERE cs.class_id = %s |
| ORDER BY u.name |
| """, (class_id,)) |
| return cur.fetchall() |
|
|
| def class_analytics(class_id:int): |
| """ |
| Returns: |
| class_avg -> 0..1 average quiz score for the class (from v_class_stats) |
| total_xp -> sum of xp_log.delta for students in this class |
| lessons_completed -> count of completed lesson_progress entries for lessons assigned to this class |
| """ |
| out = {"class_avg": 0.0, "total_xp": 0, "lessons_completed": 0} |
|
|
| with cursor() as cur: |
| |
| cur.execute("SELECT class_avg FROM v_class_stats WHERE class_id=%s", (class_id,)) |
| row = cur.fetchone() |
| if row: |
| out["class_avg"] = float(row["class_avg"] or 0) |
|
|
| |
| cur.execute(""" |
| SELECT COALESCE(SUM(x.delta),0) AS total_xp |
| FROM xp_log x |
| JOIN class_students cs ON cs.student_id = x.user_id |
| WHERE cs.class_id = %s |
| """, (class_id,)) |
| out["total_xp"] = int((cur.fetchone() or {"total_xp": 0})["total_xp"]) |
|
|
| |
| cur.execute(""" |
| SELECT COUNT(*) AS n |
| FROM lesson_progress lp |
| JOIN class_students cs ON cs.student_id = lp.user_id |
| JOIN assignments a ON a.lesson_id = lp.lesson_id |
| WHERE cs.class_id = %s |
| AND a.class_id = %s |
| AND lp.status = 'completed' |
| """, (class_id, class_id)) |
| out["lessons_completed"] = int((cur.fetchone() or {"n": 0})["n"]) |
|
|
| return out |
| |
| |
| def class_content_counts(class_id:int): |
| |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| COUNT(DISTINCT lesson_id) AS lessons, |
| COUNT(DISTINCT quiz_id) AS quizzes |
| FROM assignments |
| WHERE class_id=%s |
| """, (class_id,)) |
| row = cur.fetchone() or {"lessons": 0, "quizzes": 0} |
| return row |
|
|
| def list_class_assignments(class_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| a.assignment_id, |
| a.created_at, |
| l.lesson_id, l.title, l.subject, l.level, |
| a.quiz_id |
| FROM assignments a |
| JOIN lessons l ON l.lesson_id = a.lesson_id |
| WHERE a.class_id=%s |
| ORDER BY a.created_at DESC |
| """, (class_id,)) |
| return cur.fetchall() |
|
|
| def list_classes_by_teacher(teacher_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT s.*, c.code |
| FROM v_class_stats s |
| JOIN classes c USING (class_id) |
| WHERE s.teacher_id=%s |
| ORDER BY c.created_at DESC |
| """, (teacher_id,)) |
| return cur.fetchall() |
|
|
| def get_class(class_id:int): |
| with cursor() as cur: |
| cur.execute("SELECT class_id, name, code, teacher_id FROM classes WHERE class_id=%s", (class_id,)) |
| return cur.fetchone() |
| |
| def class_student_metrics(class_id: int): |
| """ |
| Returns one row per student in the class with: |
| name, email, joined_at, lessons_completed, total_assigned_lessons, |
| avg_score (0..1), streak_days, total_xp |
| """ |
| with cursor() as cur: |
| cur.execute(""" |
| /* total assigned lessons for the class */ |
| WITH assigned AS ( |
| SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s |
| ) |
| SELECT |
| cs.student_id, |
| u.name, |
| u.email, |
| cs.joined_at, |
| /* lessons completed by this student that were assigned to this class */ |
| COALESCE( |
| (SELECT COUNT(*) FROM lesson_progress lp |
| WHERE lp.user_id = cs.student_id |
| AND lp.status = 'completed' |
| AND lp.lesson_id IN (SELECT lesson_id FROM assigned) |
| ), 0 |
| ) AS lessons_completed, |
| /* total lessons assigned to this class */ |
| (SELECT COUNT(*) FROM assigned) AS total_assigned_lessons, |
| /* average quiz score only for submissions tied to this class */ |
| COALESCE(sc.avg_score, 0) AS avg_score, |
| /* streak days from streaks table */ |
| COALESCE(str.days, 0) AS streak_days, |
| /* total XP across the app */ |
| COALESCE(xp.total_xp, 0) AS total_xp |
| FROM class_students cs |
| JOIN users u ON u.user_id = cs.student_id |
| LEFT JOIN ( |
| SELECT s.student_id, AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_score |
| FROM submissions s |
| JOIN assignments a ON a.assignment_id = s.assignment_id |
| WHERE a.class_id = %s |
| GROUP BY s.student_id |
| ) sc ON sc.student_id = cs.student_id |
| LEFT JOIN streaks str ON str.user_id = cs.student_id |
| LEFT JOIN (SELECT user_id, SUM(delta) AS total_xp FROM xp_log GROUP BY user_id) xp |
| ON xp.user_id = cs.student_id |
| WHERE cs.class_id = %s |
| ORDER BY u.name; |
| """, (class_id, class_id, class_id)) |
| return cur.fetchall() |
|
|
| def level_from_xp(total_xp: int) -> int: |
| try: |
| xp = int(total_xp or 0) |
| except Exception: |
| xp = 0 |
| return 1 + xp // 500 |
|
|
|
|
| def list_classes_for_student(student_id: int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT c.class_id, c.name, c.code, c.teacher_id, |
| t.name AS teacher_name, cs.joined_at |
| FROM class_students cs |
| JOIN classes c ON c.class_id = cs.class_id |
| JOIN users t ON t.user_id = c.teacher_id |
| WHERE cs.student_id = %s |
| ORDER BY cs.joined_at DESC |
| """, (student_id,)) |
| return cur.fetchall() |
|
|
| def leave_class(student_id: int, class_id: int): |
| with cursor() as cur: |
| cur.execute("DELETE FROM class_students WHERE student_id=%s AND class_id=%s", |
| (student_id, class_id)) |
| return True |
|
|
| def student_class_progress(student_id: int, class_id: int): |
| """ |
| Per-student view of progress inside ONE class. |
| Returns: dict(overall_progress 0..1, lessons_completed int, |
| total_assigned_lessons int, avg_score 0..1) |
| """ |
| with cursor() as cur: |
| |
| cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s", |
| (class_id,)) |
| total_assigned = int((cur.fetchone() or {"n": 0})["n"]) |
|
|
| |
| cur.execute(""" |
| WITH assigned AS (SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s) |
| SELECT COUNT(*) AS n |
| FROM lesson_progress lp |
| WHERE lp.user_id = %s |
| AND lp.status = 'completed' |
| AND lp.lesson_id IN (SELECT lesson_id FROM assigned) |
| """, (class_id, student_id)) |
| completed = int((cur.fetchone() or {"n": 0})["n"]) |
|
|
| |
| cur.execute(""" |
| SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio |
| FROM submissions s |
| JOIN assignments a ON a.assignment_id = s.assignment_id |
| WHERE a.class_id = %s AND s.student_id = %s |
| """, (class_id, student_id)) |
| avg_score = float((cur.fetchone() or {"avg_ratio": 0.0})["avg_ratio"] or 0.0) |
|
|
| overall = (completed / float(total_assigned)) if total_assigned else 0.0 |
| return dict( |
| overall_progress=overall, |
| lessons_completed=completed, |
| total_assigned_lessons=total_assigned, |
| avg_score=avg_score |
| ) |
|
|
| def student_assignments_for_class(student_id: int, class_id: int): |
| """ |
| All assignments in a class, annotated with THIS student's status/progress |
| and (if applicable) their quiz score for that assignment. |
| Deduplicates by lesson_id (keeps the most recent assignment per lesson). |
| """ |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| a.assignment_id, a.lesson_id, l.title, l.subject, l.level, |
| a.quiz_id, a.due_at, |
| COALESCE(lp.status,'not_started') AS status, |
| lp.current_pos, |
| /* student's latest submission on this assignment (if any) */ |
| (SELECT MAX(s.submitted_at) FROM submissions s |
| WHERE s.assignment_id = a.assignment_id AND s.student_id = %s) AS last_submit_at, |
| (SELECT s2.score FROM submissions s2 |
| WHERE s2.assignment_id = a.assignment_id AND s2.student_id = %s |
| ORDER BY s2.submitted_at DESC LIMIT 1) AS score, |
| (SELECT s3.total FROM submissions s3 |
| WHERE s3.assignment_id = a.assignment_id AND s3.student_id = %s |
| ORDER BY s3.submitted_at DESC LIMIT 1) AS total |
| FROM ( |
| SELECT |
| a.*, |
| ROW_NUMBER() OVER ( |
| PARTITION BY a.lesson_id |
| ORDER BY a.created_at DESC, a.assignment_id DESC |
| ) AS rn |
| FROM assignments a |
| WHERE a.class_id = %s |
| ) AS a |
| JOIN lessons l ON l.lesson_id = a.lesson_id |
| LEFT JOIN lesson_progress lp |
| ON lp.user_id = %s AND lp.lesson_id = a.lesson_id |
| WHERE a.rn = 1 |
| ORDER BY a.created_at DESC |
| """, (student_id, student_id, student_id, class_id, student_id)) |
| return cur.fetchall() |
|
|
|
|
| |
|
|
| def update_quiz(quiz_id:int, teacher_id:int, title:str, items:list[dict], settings:dict|None=None) -> bool: |
| with cursor() as cur: |
| |
| cur.execute(""" |
| SELECT 1 |
| FROM quizzes q |
| JOIN lessons l ON l.lesson_id = q.lesson_id |
| WHERE q.quiz_id = %s AND l.teacher_id = %s |
| LIMIT 1 |
| """, (quiz_id, teacher_id)) |
| if not cur.fetchone(): |
| return False |
|
|
| cur.execute("UPDATE quizzes SET title=%s, settings=%s WHERE quiz_id=%s", |
| (title, json.dumps(settings or {}), quiz_id)) |
|
|
| cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,)) |
| for i, it in enumerate(items, start=1): |
| cur.execute(""" |
| INSERT INTO quiz_items(quiz_id, position, question, options, answer_key, points) |
| VALUES (%s, %s, %s, %s, %s, %s) |
| """, ( |
| quiz_id, i, |
| it["question"], |
| json.dumps(it.get("options", [])), |
| json.dumps(it.get("answer_key")), |
| int(it.get("points", 1)) |
| )) |
| return True |
|
|
|
|
| def class_weekly_activity(class_id:int): |
| start = date.today() - timedelta(days=6) |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT DATE(lp.last_accessed) d, COUNT(*) n |
| FROM lesson_progress lp |
| JOIN class_students cs ON cs.student_id = lp.user_id |
| WHERE cs.class_id=%s AND lp.last_accessed >= %s |
| GROUP BY DATE(lp.last_accessed) |
| """, (class_id, start)) |
| lessons = {r["d"]: r["n"] for r in cur.fetchall()} |
|
|
| cur.execute(""" |
| SELECT DATE(s.submitted_at) d, COUNT(*) n |
| FROM submissions s |
| JOIN assignments a ON a.assignment_id = s.assignment_id |
| WHERE a.class_id=%s AND s.submitted_at >= %s |
| GROUP BY DATE(s.submitted_at) |
| """, (class_id, start)) |
| quizzes = {r["d"]: r["n"] for r in cur.fetchall()} |
|
|
| cur.execute(""" |
| SELECT DATE(g.started_at) d, COUNT(*) n |
| FROM game_sessions g |
| JOIN class_students cs ON cs.student_id = g.user_id |
| WHERE cs.class_id=%s AND g.started_at >= %s |
| GROUP BY DATE(g.started_at) |
| """, (class_id, start)) |
| games = {r["d"]: r["n"] for r in cur.fetchall()} |
|
|
| out = [] |
| for i in range(7): |
| d = start + timedelta(days=i) |
| out.append({ |
| "date": d, |
| "lessons": lessons.get(d, 0), |
| "quizzes": quizzes.get(d, 0), |
| "games": games.get(d, 0), |
| }) |
| return out |
|
|
|
|
|
|
|
|
| def update_lesson(lesson_id:int, teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]) -> bool: |
| with cursor() as cur: |
| |
| cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id)) |
| if not cur.fetchone(): |
| return False |
|
|
| cur.execute(""" |
| UPDATE lessons |
| SET title=%s, description=%s, subject=%s, level=%s |
| WHERE lesson_id=%s AND teacher_id=%s |
| """, (title, description, subject, level_slug, lesson_id, teacher_id)) |
|
|
| |
| cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,)) |
| for i, sec in enumerate(sections, start=1): |
| cur.execute(""" |
| INSERT INTO lesson_sections(lesson_id,position,title,content) |
| VALUES(%s,%s,%s,%s) |
| """, (lesson_id, i, sec.get("title"), sec.get("content"))) |
| return True |
|
|
|
|
| |
| def class_progress_overview(class_id: int): |
| """ |
| Returns: |
| { |
| "overall_progress": 0..1, |
| "quiz_performance": 0..1, |
| "lessons_completed": int, |
| "class_xp": int |
| } |
| """ |
| with cursor() as cur: |
| |
| cur.execute("SELECT COUNT(DISTINCT lesson_id) AS n FROM assignments WHERE class_id=%s", (class_id,)) |
| total_assigned = int((cur.fetchone() or {"n": 0})["n"]) |
|
|
| |
| cur.execute("SELECT COUNT(*) AS n FROM class_students WHERE class_id=%s", (class_id,)) |
| num_students = int((cur.fetchone() or {"n": 0})["n"]) |
|
|
| |
| cur.execute(""" |
| WITH assigned AS ( |
| SELECT DISTINCT lesson_id FROM assignments WHERE class_id = %s |
| ), enrolled AS ( |
| SELECT student_id FROM class_students WHERE class_id = %s |
| ), per_student AS ( |
| SELECT e.student_id, |
| COUNT(DISTINCT CASE |
| WHEN lp.status='completed' AND lp.lesson_id IN (SELECT lesson_id FROM assigned) |
| THEN lp.lesson_id END) AS completed |
| FROM enrolled e |
| LEFT JOIN lesson_progress lp ON lp.user_id = e.student_id |
| GROUP BY e.student_id |
| ) |
| SELECT COALESCE(SUM(completed),0) AS total_completed |
| FROM per_student |
| """, (class_id, class_id)) |
| total_completed = int((cur.fetchone() or {"total_completed": 0})["total_completed"] or 0) |
|
|
| |
| cur.execute(""" |
| SELECT AVG(s.score * 1.0 / NULLIF(s.total,0)) AS avg_ratio |
| FROM submissions s |
| JOIN assignments a ON a.assignment_id = s.assignment_id |
| WHERE a.class_id = %s |
| """, (class_id,)) |
| quiz_perf_row = cur.fetchone() or {"avg_ratio": 0} |
| quiz_perf = float(quiz_perf_row["avg_ratio"] or 0) |
|
|
| |
| cur.execute(""" |
| SELECT COALESCE(SUM(x.delta),0) AS xp |
| FROM xp_log x |
| WHERE x.user_id IN (SELECT student_id FROM class_students WHERE class_id=%s) |
| """, (class_id,)) |
| class_xp = int((cur.fetchone() or {"xp": 0})["xp"] or 0) |
|
|
| if total_assigned and num_students: |
| denominator = float(total_assigned * num_students) |
| overall = float(total_completed) / denominator |
| else: |
| overall = 0.0 |
|
|
| return dict( |
| overall_progress=float(overall), |
| quiz_performance=float(quiz_perf), |
| lessons_completed=int(total_completed), |
| class_xp=int(class_xp), |
| ) |
|
|
| |
| def class_recent_activity(class_id:int, limit:int=6, days:int=30): |
| """ |
| Returns latest activity rows with fields: |
| ts, kind('lesson'|'quiz'|'game'), student_id, student_name, item_title, extra, total_xp |
| """ |
| with cursor() as cur: |
| cur.execute(f""" |
| WITH enrolled AS ( |
| SELECT student_id FROM class_students WHERE class_id = %s |
| ), |
| xp AS ( |
| SELECT user_id, COALESCE(SUM(delta),0) AS total_xp |
| FROM xp_log GROUP BY user_id |
| ) |
| SELECT * FROM ( |
| /* completed lessons */ |
| SELECT lp.last_accessed AS ts, |
| 'lesson' AS kind, |
| u.user_id AS student_id, |
| u.name AS student_name, |
| l.title AS item_title, |
| NULL AS extra, |
| COALESCE(xp.total_xp,0) AS total_xp |
| FROM lesson_progress lp |
| JOIN enrolled e ON e.student_id = lp.user_id |
| JOIN users u ON u.user_id = lp.user_id |
| JOIN lessons l ON l.lesson_id = lp.lesson_id |
| LEFT JOIN xp ON xp.user_id = u.user_id |
| WHERE lp.status = 'completed' AND lp.last_accessed >= NOW() - INTERVAL {days} DAY |
| |
| UNION ALL |
| |
| /* quiz submissions */ |
| SELECT s.submitted_at AS ts, |
| 'quiz' AS kind, |
| u.user_id AS student_id, |
| u.name AS student_name, |
| l.title AS item_title, |
| CONCAT(ROUND(s.score*100.0/NULLIF(s.total,0)),'%') AS extra, |
| COALESCE(xp.total_xp,0) AS total_xp |
| FROM submissions s |
| JOIN assignments a ON a.assignment_id = s.assignment_id AND a.class_id = %s |
| JOIN users u ON u.user_id = s.student_id |
| JOIN lessons l ON l.lesson_id = a.lesson_id |
| LEFT JOIN xp ON xp.user_id = u.user_id |
| WHERE s.submitted_at >= NOW() - INTERVAL {days} DAY |
| |
| UNION ALL |
| |
| /* games */ |
| SELECT g.started_at AS ts, |
| 'game' AS kind, |
| u.user_id AS student_id, |
| u.name AS student_name, |
| g.game_slug AS item_title, |
| NULL AS extra, |
| COALESCE(xp.total_xp,0) AS total_xp |
| FROM game_sessions g |
| JOIN enrolled e ON e.student_id = g.user_id |
| JOIN users u ON u.user_id = g.user_id |
| LEFT JOIN xp ON xp.user_id = u.user_id |
| WHERE g.started_at >= NOW() - INTERVAL {days} DAY |
| ) x |
| ORDER BY ts DESC |
| LIMIT %s |
| """, (class_id, class_id, limit)) |
| return cur.fetchall() |
|
|
|
|
|
|
| def list_quizzes_by_teacher(teacher_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT q.quiz_id, q.title, q.created_at, |
| l.title AS lesson_title, |
| (SELECT COUNT(*) FROM quiz_items qi WHERE qi.quiz_id=q.quiz_id) AS num_items |
| FROM quizzes q |
| JOIN lessons l ON l.lesson_id=q.lesson_id |
| WHERE l.teacher_id=%s |
| ORDER BY q.created_at DESC |
| """, (teacher_id,)) |
| return cur.fetchall() |
|
|
| def list_all_students_for_teacher(teacher_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT DISTINCT u.user_id, u.name, u.email |
| FROM classes c |
| JOIN class_students cs ON cs.class_id=c.class_id |
| JOIN users u ON u.user_id=cs.student_id |
| WHERE c.teacher_id=%s |
| ORDER BY u.name |
| """, (teacher_id,)) |
| return cur.fetchall() |
|
|
| |
|
|
| def list_assigned_students_for_lesson(lesson_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| WITH direct AS ( |
| SELECT student_id FROM assignments |
| WHERE lesson_id=%s AND student_id IS NOT NULL |
| ), |
| via_class AS ( |
| SELECT cs.student_id |
| FROM assignments a |
| JOIN class_students cs ON cs.class_id=a.class_id |
| WHERE a.lesson_id=%s AND a.class_id IS NOT NULL |
| ), |
| all_students AS ( |
| SELECT student_id FROM direct |
| UNION |
| SELECT student_id FROM via_class |
| ) |
| SELECT u.user_id, u.name, u.email |
| FROM users u |
| JOIN all_students s ON s.student_id=u.user_id |
| ORDER BY u.name |
| """, (lesson_id, lesson_id)) |
| return cur.fetchall() |
|
|
| def list_assigned_students_for_quiz(quiz_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| WITH direct AS ( |
| SELECT student_id FROM assignments |
| WHERE quiz_id=%s AND student_id IS NOT NULL |
| ), |
| via_class AS ( |
| SELECT cs.student_id |
| FROM assignments a |
| JOIN class_students cs ON cs.class_id=a.class_id |
| WHERE a.quiz_id=%s AND a.class_id IS NOT NULL |
| ), |
| all_students AS ( |
| SELECT student_id FROM direct |
| UNION |
| SELECT student_id FROM via_class |
| ) |
| SELECT u.user_id, u.name, u.email |
| FROM users u |
| JOIN all_students s ON s.student_id=u.user_id |
| ORDER BY u.name |
| """, (quiz_id, quiz_id)) |
| return cur.fetchall() |
|
|
| |
|
|
| def assign_lesson_to_students(lesson_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None): |
| |
| with cursor() as cur: |
| for sid in student_ids: |
| cur.execute(""" |
| INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at) |
| VALUES(%s, NULL, %s, %s, %s) |
| ON DUPLICATE KEY UPDATE due_at=VALUES(due_at) |
| """, (lesson_id, sid, teacher_id, due_at)) |
| return True |
|
|
| def assign_quiz_to_students(quiz_id:int, student_ids:list[int], teacher_id:int, due_at:str|None=None): |
| |
| with cursor() as cur: |
| cur.execute("SELECT lesson_id FROM quizzes WHERE quiz_id=%s", (quiz_id,)) |
| row = cur.fetchone() |
| if not row: |
| raise ValueError("Quiz not found") |
| lesson_id = row["lesson_id"] |
| for sid in student_ids: |
| cur.execute(""" |
| INSERT INTO assignments(lesson_id, quiz_id, student_id, assigned_by, due_at) |
| VALUES(%s, %s, %s, %s, %s) |
| ON DUPLICATE KEY UPDATE due_at=VALUES(due_at) |
| """, (lesson_id, quiz_id, sid, teacher_id, due_at)) |
| return True |
|
|
| |
|
|
| def delete_lesson(lesson_id:int, teacher_id:int): |
| with cursor() as cur: |
| |
| cur.execute("SELECT 1 FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id)) |
| if not cur.fetchone(): |
| return False, "You can only delete own lesson." |
| |
| cur.execute("SELECT COUNT(*) AS n FROM assignments WHERE lesson_id=%s", (lesson_id,)) |
| if cur.fetchone()["n"] > 0: |
| return False, "Remove assignments first." |
| cur.execute("SELECT COUNT(*) AS n FROM quizzes WHERE lesson_id=%s", (lesson_id,)) |
| if cur.fetchone()["n"] > 0: |
| return False, "Delete quizzes for this lesson first." |
| |
| cur.execute("DELETE FROM lesson_sections WHERE lesson_id=%s", (lesson_id,)) |
| cur.execute("DELETE FROM lessons WHERE lesson_id=%s AND teacher_id=%s", (lesson_id, teacher_id)) |
| return True, "Deleted." |
|
|
| def delete_quiz(quiz_id:int, teacher_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT 1 |
| FROM quizzes q JOIN lessons l ON l.lesson_id=q.lesson_id |
| WHERE q.quiz_id=%s AND l.teacher_id=%s |
| """, (quiz_id, teacher_id)) |
| if not cur.fetchone(): |
| return False, "You can only delete own quiz." |
| cur.execute("SELECT COUNT(*) AS n FROM submissions WHERE quiz_id=%s", (quiz_id,)) |
| if cur.fetchone()["n"] > 0: |
| return False, "This quiz has submissions. Deleting is blocked." |
| cur.execute("DELETE FROM quiz_items WHERE quiz_id=%s", (quiz_id,)) |
| cur.execute("DELETE FROM assignments WHERE quiz_id=%s", (quiz_id,)) |
| cur.execute("DELETE FROM quizzes WHERE quiz_id=%s", (quiz_id,)) |
| return True, "Deleted." |
| |
|
|
| def _bump_game_stats(user_id:int, slug:str, *, gained_xp:int, matched:int|None=None, level_inc:int=0): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level) |
| VALUES(%s,%s,%s,%s,%s) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| matches = matches + VALUES(matches), |
| level = GREATEST(level, VALUES(level)) |
| """, (user_id, slug, int(gained_xp), int(matched or 1), level_inc)) |
|
|
| |
| def create_lesson(teacher_id:int, title:str, description:str, subject:str, level_slug:str, sections:list[dict]): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO lessons(teacher_id,title,description,subject,level,duration_min) |
| VALUES(%s,%s,%s,%s,%s,%s) |
| """, (teacher_id, title, description, subject, level_slug, 60)) |
| cur.execute("SELECT LAST_INSERT_ID() AS id") |
| lesson_id = cur.fetchone()["id"] |
| for i, sec in enumerate(sections, start=1): |
| cur.execute(""" |
| INSERT INTO lesson_sections(lesson_id,position,title,content) |
| VALUES(%s,%s,%s,%s) |
| """, (lesson_id, i, sec.get("title"), sec.get("content"))) |
| return lesson_id |
|
|
| def list_lessons_by_teacher(teacher_id:int): |
| with cursor() as cur: |
| cur.execute("SELECT * FROM lessons WHERE teacher_id=%s ORDER BY created_at DESC", (teacher_id,)) |
| return cur.fetchall() |
|
|
| def get_lesson(lesson_id:int): |
| with cursor() as cur: |
| cur.execute("SELECT * FROM lessons WHERE lesson_id=%s", (lesson_id,)) |
| lesson = cur.fetchone() |
| cur.execute("SELECT * FROM lesson_sections WHERE lesson_id=%s ORDER BY position", (lesson_id,)) |
| sections = cur.fetchall() |
| return {"lesson": lesson, "sections": sections} |
|
|
| |
| def create_quiz(lesson_id:int, title:str, items:list[dict], settings:dict|None=None): |
| with cursor() as cur: |
| cur.execute("INSERT INTO quizzes(lesson_id,title,settings) VALUES(%s,%s,%s)", |
| (lesson_id, title, json.dumps(settings or {}))) |
| cur.execute("SELECT LAST_INSERT_ID() AS id") |
| quiz_id = cur.fetchone()["id"] |
| for i, it in enumerate(items, start=1): |
| cur.execute(""" |
| INSERT INTO quiz_items(quiz_id,position,question,options,answer_key,points) |
| VALUES(%s,%s,%s,%s,%s,%s) |
| """, (quiz_id, i, it["question"], json.dumps(it.get("options", [])), |
| json.dumps(it.get("answer_key")), int(it.get("points", 1)))) |
| return quiz_id |
|
|
| def get_quiz(quiz_id:int): |
| with cursor() as cur: |
| cur.execute("SELECT * FROM quizzes WHERE quiz_id=%s", (quiz_id,)) |
| quiz = cur.fetchone() |
| cur.execute("SELECT * FROM quiz_items WHERE quiz_id=%s ORDER BY position", (quiz_id,)) |
| items = cur.fetchall() |
| return {"quiz": quiz, "items": items} |
|
|
| |
| def assign_to_class(lesson_id:int, quiz_id:int|None, class_id:int, teacher_id:int, due_at:str|None=None): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO assignments(lesson_id,quiz_id,class_id,assigned_by,due_at) |
| VALUES(%s,%s,%s,%s,%s) |
| """, (lesson_id, quiz_id, class_id, teacher_id, due_at)) |
| cur.execute("SELECT LAST_INSERT_ID() AS id") |
| return cur.fetchone()["id"] |
|
|
| def assign_to_student(lesson_id:int, quiz_id:int|None, student_id:int, teacher_id:int, due_at:str|None=None): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO assignments(lesson_id,quiz_id,student_id,assigned_by,due_at) |
| VALUES(%s,%s,%s,%s,%s) |
| """, (lesson_id, quiz_id, student_id, teacher_id, due_at)) |
| cur.execute("SELECT LAST_INSERT_ID() AS id") |
| return cur.fetchone()["id"] |
|
|
| def list_assignments_for_student(student_id:int): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| a.assignment_id, a.lesson_id, l.title, l.subject, l.level, |
| a.quiz_id, a.due_at, |
| COALESCE(lp.status,'not_started') AS status, |
| lp.current_pos |
| FROM ( |
| SELECT |
| a.*, |
| ROW_NUMBER() OVER ( |
| PARTITION BY a.lesson_id |
| ORDER BY a.created_at DESC, a.assignment_id DESC |
| ) AS rn |
| FROM assignments a |
| WHERE a.student_id = %s |
| OR a.class_id IN (SELECT class_id FROM class_students WHERE student_id = %s) |
| ) AS a |
| JOIN lessons l ON l.lesson_id = a.lesson_id |
| LEFT JOIN lesson_progress lp |
| ON lp.user_id = %s AND lp.lesson_id = a.lesson_id |
| WHERE a.rn = 1 |
| ORDER BY a.created_at DESC |
| """, (student_id, student_id, student_id)) |
| return cur.fetchall() |
|
|
|
|
| |
| def save_progress(user_id:int, lesson_id:int, current_pos:int, status:str): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO lesson_progress(user_id,lesson_id,current_pos,status) |
| VALUES(%s,%s,%s,%s) |
| ON DUPLICATE KEY UPDATE current_pos=VALUES(current_pos), status=VALUES(status) |
| """, (user_id, lesson_id, current_pos, status)) |
| return True |
|
|
| def submit_quiz(student_id:int, assignment_id:int, quiz_id:int, score:int, total:int, details:dict): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO submissions(assignment_id,quiz_id,student_id,score,total,details) |
| VALUES(%s,%s,%s,%s,%s,%s) |
| ON DUPLICATE KEY UPDATE score=VALUES(score), total=VALUES(total), details=VALUES(details), submitted_at=CURRENT_TIMESTAMP |
| """, (assignment_id, quiz_id, student_id, score, total, json.dumps(details))) |
| |
| return True |
|
|
| |
| def teacher_tiles(teacher_id:int): |
| with cursor() as cur: |
| cur.execute("SELECT * FROM v_class_stats WHERE teacher_id=%s", (teacher_id,)) |
| rows = cur.fetchall() |
| total_students = sum(r["total_students"] for r in rows) |
| lessons_created = _count_lessons(teacher_id) |
| |
| class_avg = round(sum(r["class_avg"] for r in rows)/len(rows), 2) if rows else 0 |
| active_students = sum(1 for r in rows if r.get("recent_submissions",0) > 0) |
| return dict(total_students=total_students, class_avg=class_avg, lessons_created=lessons_created, active_students=active_students) |
|
|
| def _count_lessons(teacher_id:int): |
| with cursor() as cur: |
| cur.execute("SELECT COUNT(*) AS n FROM lessons WHERE teacher_id=%s", (teacher_id,)) |
| return cur.fetchone()["n"] |
|
|
|
|
| |
| def user_xp_and_level(user_id: int, base: int = 500): |
| with cursor() as cur: |
| cur.execute("SELECT COALESCE(SUM(delta),0) AS xp FROM xp_log WHERE user_id=%s", (user_id,)) |
| xp = int((cur.fetchone() or {"xp": 0})["xp"]) |
| cur.execute("SELECT COALESCE(days,0) AS days FROM streaks WHERE user_id=%s", (user_id,)) |
| streak = int((cur.fetchone() or {"days": 0})["days"]) |
|
|
| |
| level = max(1, xp // base + 1) |
| start_of_level = (level - 1) * base |
| into = xp - start_of_level |
| need = base |
| |
| if into == need: |
| level += 1 |
| into = 0 |
|
|
| return { |
| "xp": xp, |
| "level": level, |
| "into": into, |
| "need": need, |
| "streak": streak, |
| } |
|
|
| def recent_lessons_for_student(user_id:int, limit:int=5): |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT l.title, |
| CASE WHEN lp.status='completed' THEN 100 |
| WHEN lp.current_pos IS NULL THEN 0 |
| ELSE LEAST(95, lp.current_pos * 10) |
| END AS progress |
| FROM lessons l |
| LEFT JOIN lesson_progress lp |
| ON lp.lesson_id=l.lesson_id AND lp.user_id=%s |
| WHERE l.lesson_id IN ( |
| SELECT lesson_id FROM assignments |
| WHERE student_id=%s |
| OR class_id IN (SELECT class_id FROM class_students WHERE student_id=%s) |
| ) |
| ORDER BY l.created_at DESC |
| LIMIT %s |
| """, (user_id, user_id, user_id, limit)) |
| return cur.fetchall() |
|
|
| def student_quiz_average(student_id: int) -> int: |
| """ |
| Returns the student's average quiz percentage (0–100) using the latest |
| submission per quiz from the `submissions` table. |
| """ |
| with cursor() as cur: |
| cur.execute(""" |
| WITH latest AS ( |
| SELECT quiz_id, MAX(submitted_at) AS last_ts |
| FROM submissions |
| WHERE student_id = %s |
| GROUP BY quiz_id |
| ) |
| SELECT ROUND(AVG(s.score * 100.0 / NULLIF(s.total,0))) AS pct |
| FROM latest t |
| JOIN submissions s |
| ON s.quiz_id = t.quiz_id |
| AND s.submitted_at = t.last_ts |
| WHERE s.student_id = %s |
| """, (student_id, student_id)) |
| row = cur.fetchone() or {} |
| return int(row.get("pct") or 0) |
|
|
| |
| def add_xp(user_id:int, delta:int, source:str, meta:dict|None=None): |
| with cursor() as cur: |
| cur.execute( |
| "INSERT INTO xp_log(user_id,source,delta,meta) VALUES(%s,%s,%s,%s)", |
| (user_id, source, int(delta), json.dumps(meta or {})) |
| ) |
| |
| cur.execute("SELECT days, last_active FROM streaks WHERE user_id=%s", (user_id,)) |
| row = cur.fetchone() |
| today = date.today() |
| if not row: |
| cur.execute("INSERT INTO streaks(user_id,days,last_active) VALUES(%s,%s,%s)", (user_id, 1, today)) |
| else: |
| last = row["last_active"] |
| days = int(row["days"] or 0) |
| if last is None or last < today: |
| |
| if last and (today - last) > timedelta(days=1): |
| days = 1 |
| else: |
| days = max(1, days + 1) |
| cur.execute("UPDATE streaks SET days=%s,last_active=%s WHERE user_id=%s", (days, today, user_id)) |
|
|
| |
|
|
| def leaderboard_for_class(class_id: int, limit: int = 10): |
| """ |
| Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...] |
| Sorted by XP (desc) for students in a specific class. |
| """ |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| u.user_id, |
| u.name, |
| COALESCE(x.total_xp, 0) AS xp |
| FROM class_students cs |
| JOIN users u ON u.user_id = cs.student_id |
| LEFT JOIN ( |
| SELECT user_id, SUM(delta) AS total_xp |
| FROM xp_log |
| GROUP BY user_id |
| ) x ON x.user_id = u.user_id |
| WHERE cs.class_id = %s |
| ORDER BY COALESCE(x.total_xp, 0) DESC, u.name |
| LIMIT %s |
| """, (class_id, limit)) |
| rows = cur.fetchall() or [] |
| |
| for r in rows: |
| r["level"] = level_from_xp(r.get("xp", 0)) |
| return rows |
|
|
|
|
| def leaderboard_global(limit: int = 10): |
| """ |
| Returns: [{'user_id': int, 'name': str, 'xp': int, 'level': int}, ...] |
| Top students across the whole app by XP. |
| """ |
| with cursor() as cur: |
| cur.execute(""" |
| SELECT |
| u.user_id, |
| u.name, |
| COALESCE(x.total_xp, 0) AS xp |
| FROM users u |
| LEFT JOIN ( |
| SELECT user_id, SUM(delta) AS total_xp |
| FROM xp_log |
| GROUP BY user_id |
| ) x ON x.user_id = u.user_id |
| WHERE u.role_slug = 'student' |
| ORDER BY COALESCE(x.total_xp, 0) DESC, u.name |
| LIMIT %s |
| """, (limit,)) |
| rows = cur.fetchall() or [] |
| for r in rows: |
| r["level"] = level_from_xp(r.get("xp", 0)) |
| return rows |
|
|
|
|
|
|
|
|
| |
| def record_money_match_play(user_id:int, *, target:int, total:int, elapsed_ms:int, matched:bool, gained_xp:int): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO game_sessions(user_id,game_slug,target,total,elapsed_ms,matched,gained_xp,ended_at) |
| VALUES(%s,'money_match',%s,%s,%s,%s,%s,NOW()) |
| """, (user_id, target, total, elapsed_ms, 1 if matched else 0, gained_xp)) |
| cur.execute(""" |
| INSERT INTO money_match_history(user_id,target,total,elapsed_ms,gained_xp,matched) |
| VALUES(%s,%s,%s,%s,%s,%s) |
| """, (user_id, target, total, elapsed_ms, gained_xp, 1 if matched else 0)) |
| cur.execute(""" |
| INSERT INTO money_match_stats(user_id,total_xp,matches,best_time_ms,best_target) |
| VALUES(%s,%s,%s,%s,%s) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| matches = matches + VALUES(matches), |
| best_time_ms = LEAST(COALESCE(best_time_ms, VALUES(best_time_ms)), VALUES(best_time_ms)), |
| best_target = COALESCE(best_target, VALUES(best_target)) |
| """, (user_id, gained_xp, 1 if matched else 0, elapsed_ms if matched else None, target if matched else None)) |
|
|
| _bump_game_stats(user_id, "money_match", gained_xp=gained_xp, matched=1 if matched else 0) |
| add_xp(user_id, gained_xp, "game", {"game":"money_match","target":target,"total":total,"elapsed_ms":elapsed_ms,"matched":matched}) |
|
|
| def record_budget_builder_save(user_id:int, *, weekly_allowance:int, allocations:list[dict]): |
| total_allocated = sum(int(x.get("amount",0)) for x in allocations) |
| remaining = int(weekly_allowance) - total_allocated |
| gained_xp = 150 if remaining == 0 else 100 if remaining > 0 else 50 |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at) |
| VALUES(%s,'budget_builder',%s,NOW()) |
| """, (user_id, gained_xp)) |
| cur.execute(""" |
| INSERT INTO budget_builder_history(user_id,weekly_allowance,allocations,total_allocated,remaining,gained_xp) |
| VALUES(%s,%s,%s,%s,%s,%s) |
| """, (user_id, weekly_allowance, json.dumps(allocations), total_allocated, remaining, gained_xp)) |
| cur.execute(""" |
| INSERT INTO budget_builder_stats(user_id,total_xp,plays,best_balance) |
| VALUES(%s,%s,1,%s) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| plays = plays + 1, |
| best_balance = GREATEST(COALESCE(best_balance, 0), VALUES(best_balance)) |
| """, (user_id, gained_xp, remaining)) |
|
|
| _bump_game_stats(user_id, "budget_builder", gained_xp=gained_xp, matched=1) |
| add_xp(user_id, gained_xp, "game", {"game":"budget_builder","remaining":remaining}) |
|
|
| def record_debt_dilemma_round( |
| user_id:int, *, |
| level:int, round_no:int, |
| wallet:int, health:int, happiness:int, credit_score:int, |
| event_json:dict, outcome:str, gained_xp:int |
| ): |
| with cursor() as cur: |
| cur.execute(""" |
| INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at) |
| VALUES(%s,'debt_dilemma',%s,NOW()) |
| """, (user_id, gained_xp)) |
| cur.execute(""" |
| INSERT INTO debt_dilemma_history(user_id,level,round_no,wallet,health,happiness,credit_score,event_json,outcome,gained_xp) |
| VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) |
| """, (user_id, level, round_no, wallet, health, happiness, credit_score, json.dumps(event_json or {}), outcome, gained_xp)) |
| cur.execute(""" |
| INSERT INTO debt_dilemma_stats(user_id,total_xp,plays,highest_level,last_outcome) |
| VALUES(%s,%s,1,%s,%s) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| plays = plays + 1, |
| highest_level = GREATEST(COALESCE(highest_level,0), VALUES(highest_level)), |
| last_outcome = VALUES(last_outcome) |
| """, (user_id, gained_xp, level, outcome)) |
|
|
| |
| _bump_game_stats(user_id, "debt_dilemma", gained_xp=gained_xp, matched=1, level_inc=level) |
| add_xp(user_id, gained_xp, "game", { |
| "game":"debt_dilemma","level":level,"round":round_no,"outcome":outcome |
| }) |
|
|
|
|
| def record_profit_puzzle_result( |
| user_id:int, *, |
| scenario_id:str, |
| title:str, |
| units:int, price:int, cost:int, |
| user_answer:float, actual_profit:float, |
| is_correct:bool, gained_xp:int |
| ): |
| with cursor() as cur: |
| |
| cur.execute(""" |
| INSERT INTO game_sessions(user_id,game_slug,gained_xp,ended_at) |
| VALUES(%s,'profit_puzzle',%s,NOW()) |
| """, (user_id, int(gained_xp))) |
|
|
| |
| cur.execute(""" |
| INSERT INTO profit_puzzle_history |
| (user_id,scenario_id,title,units,price,cost,user_answer,actual_profit,is_correct,gained_xp) |
| VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) |
| """, (user_id, scenario_id, title, int(units), int(price), int(cost), |
| float(user_answer), float(actual_profit), 1 if is_correct else 0, int(gained_xp))) |
|
|
| |
| cur.execute(""" |
| INSERT INTO profit_puzzle_stats(user_id,total_xp,plays,correct,last_score) |
| VALUES(%s,%s,1,%s,%s) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| plays = plays + 1, |
| correct = correct + VALUES(correct), |
| last_score = VALUES(last_score), |
| last_played = CURRENT_TIMESTAMP |
| """, (user_id, int(gained_xp), 1 if is_correct else 0, int(gained_xp))) |
|
|
| |
| cur.execute(""" |
| INSERT INTO game_stats(user_id,game_slug,total_xp,matches,level) |
| VALUES(%s,'profit_puzzle',%s,%s,1) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| matches = matches + VALUES(matches) |
| """, (user_id, int(gained_xp), 1 if is_correct else 0)) |
|
|
| |
| add_xp(user_id, int(gained_xp), "game", |
| {"game":"profit_puzzle","scenario":scenario_id,"correct":bool(is_correct)}) |
|
|
| |
| def record_profit_puzzle_progress(user_id:int, *, scenario_title:str, correct:bool, gained_xp:int): |
| """ |
| Log a Profit Puzzle step and bump XP. |
| - Writes to generic game_sessions and game_stats |
| - Writes to xp_log via add_xp |
| """ |
| with cursor() as cur: |
| |
| cur.execute(""" |
| INSERT INTO game_sessions(user_id, game_slug, gained_xp, ended_at) |
| VALUES(%s, 'profit_puzzle', %s, NOW()) |
| """, (user_id, int(gained_xp))) |
|
|
| |
| cur.execute(""" |
| INSERT INTO game_stats(user_id, game_slug, total_xp, matches, level) |
| VALUES(%s, 'profit_puzzle', %s, %s, 1) |
| ON DUPLICATE KEY UPDATE |
| total_xp = total_xp + VALUES(total_xp), |
| matches = matches + VALUES(matches) |
| """, (user_id, int(gained_xp), 1 if correct else 0)) |
|
|
| add_xp( |
| user_id, |
| int(gained_xp), |
| "game", |
| {"game": "profit_puzzle", "scenario": scenario_title, "correct": bool(correct)} |
| ) |
|
|