Files
EXAM-MASTER/app.py
Echo-Whale 1f9fc4a164 feat: 增强错题管理和用户界面,优化用户体验
Web端 优化UI界面
ANDROID端
- 在错题页面中添加详细统计信息,显示错题数量和已练习情况
- 实现单个错题的练习功能,用户可以直接练习特定错题
- 增加标记错题为已掌握的功能,用户可以管理自己的错题库
- 优化题目卡片,显示作答次数,提升用户对题目的掌握情况
- 更新导航结构,增加错题练习的入口,提升功能可用性
- 更新导航结构整合精简重复入口
这些改进旨在提升用户在错题管理和练习过程中的体验,帮助用户更有效地复习和掌握知识点。
2025-06-16 11:10:07 +08:00

2503 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
EXAM-MASTER - A Flask-based Online Quiz System
This application provides a complete quiz system with features including:
- User registration and authentication
- Question management with import from CSV
- Multiple quiz modes (random, sequential, timed, exam)
- User progress tracking and statistics
- Favorites, tags, and search functionality
Author: ShayneChen (xinyu-c@outlook.com)
License: MIT
"""
# Standard library imports
import csv
import json
import os
import random
import sqlite3
import time
from datetime import datetime, timedelta
from functools import wraps
# Third-party imports
from flask import (
Flask,
request,
render_template,
session,
redirect,
url_for,
flash,
jsonify,
abort,
send_file
)
from werkzeug.security import generate_password_hash, check_password_hash
# Initialize Flask application
app = Flask(__name__)
# Use environment variable for secret key with a fallback default
# In production, always set this through environment variables
app.secret_key = os.environ.get('SECRET_KEY', 'change_this_in_production')
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7)
# Add built-in functions to Jinja2 global environment
app.jinja_env.globals.update(max=max, min=min)
#############################
# Database Helper Functions #
#############################
def get_db():
"""
Create a database connection and configure it to return rows as dictionaries.
Returns:
sqlite3.Connection: The configured database connection
"""
conn = sqlite3.connect('database.db')
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""
Initialize the database by creating necessary tables if they don't exist.
Also loads initial question data from CSV if the questions table is empty.
"""
conn = get_db()
c = conn.cursor()
# Users table
c.execute('''CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
current_seq_qid TEXT,
auto_jump_next INTEGER DEFAULT 0,
current_bank_id INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
# Add columns to existing users table if they don't exist
try:
c.execute('ALTER TABLE users ADD COLUMN auto_jump_next INTEGER DEFAULT 0')
except sqlite3.OperationalError:
# Column already exists
pass
try:
c.execute('ALTER TABLE users ADD COLUMN current_bank_id INTEGER DEFAULT 1')
except sqlite3.OperationalError:
# Column already exists
pass
# History table for tracking user answers
c.execute('''CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
question_id TEXT NOT NULL,
user_answer TEXT NOT NULL,
correct INTEGER NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
# Questions table for storing question data
c.execute('''CREATE TABLE IF NOT EXISTS questions (
id TEXT PRIMARY KEY,
stem TEXT NOT NULL,
answer TEXT NOT NULL,
difficulty TEXT,
qtype TEXT,
category TEXT,
options TEXT, -- JSON stored options
bank_id INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (bank_id) REFERENCES question_banks(id)
)''')
# Add bank_id column to existing questions table if it doesn't exist
try:
c.execute('ALTER TABLE questions ADD COLUMN bank_id INTEGER DEFAULT 1')
except sqlite3.OperationalError:
# Column already exists
pass
# Favorites table for user bookmarks
c.execute('''CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
question_id TEXT NOT NULL,
tag TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, question_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (question_id) REFERENCES questions(id)
)''')
# Exam sessions table for timed mode and exams
c.execute('''CREATE TABLE IF NOT EXISTS exam_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
mode TEXT NOT NULL, -- 'exam' or 'timed'
question_ids TEXT NOT NULL, -- JSON list
start_time DATETIME NOT NULL,
duration INTEGER NOT NULL, -- seconds
completed BOOLEAN DEFAULT 0,
score REAL,
FOREIGN KEY (user_id) REFERENCES users(id)
)''')
# Question banks table for managing multiple question banks
c.execute('''CREATE TABLE IF NOT EXISTS question_banks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
filename TEXT,
question_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)''')
# Add new columns to existing question_banks table if they don't exist
try:
c.execute('ALTER TABLE question_banks ADD COLUMN filename TEXT')
except sqlite3.OperationalError:
pass
try:
c.execute('ALTER TABLE question_banks ADD COLUMN question_count INTEGER DEFAULT 0')
except sqlite3.OperationalError:
pass
conn.commit()
# Initialize default question bank if not exists
c.execute('SELECT COUNT(*) as cnt FROM question_banks')
if c.fetchone()['cnt'] == 0:
c.execute('''INSERT INTO question_banks (name, description, filename)
VALUES (?, ?, ?)''',
('默认题库', '系统默认题库', 'questions.csv'))
conn.commit()
# Load questions from CSV if the table is empty
c.execute('SELECT COUNT(*) as cnt FROM questions')
if c.fetchone()['cnt'] == 0:
load_questions_to_db(conn)
conn.close()
def load_questions_to_db(conn, bank_id=1):
"""
Load questions from a CSV file into the database.
Args:
conn (sqlite3.Connection): The database connection
bank_id (int): The question bank ID to associate questions with
"""
try:
with open('questions.csv', 'r', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
c = conn.cursor()
for row in reader:
options = {}
for opt in ['A', 'B', 'C', 'D', 'E']:
if row.get(opt) and row[opt].strip():
options[opt] = row[opt]
c.execute(
"INSERT INTO questions (id, stem, answer, difficulty, qtype, category, options, bank_id) VALUES (?,?,?,?,?,?,?,?)",
(
row["题号"],
row["题干"],
row["答案"],
row["难度"],
row["题型"],
row.get("类别", "未分类"),
json.dumps(options, ensure_ascii=False),
bank_id,
),
)
conn.commit()
except FileNotFoundError:
print("Warning: questions.csv file not found. No questions loaded.")
except Exception as e:
print(f"Error loading questions: {e}")
# Initialize the database
init_db()
################################
# Authentication Helper Functions #
################################
def login_required(f):
"""
Decorator to require login for a route.
Args:
f (function): The route function to decorate
Returns:
function: The decorated function
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not is_logged_in():
flash("请先登录后再访问该页面", "error")
return redirect(url_for('login', next=request.url))
return f(*args, **kwargs)
return decorated_function
def is_logged_in():
"""
Check if the user is logged in.
Returns:
bool: True if user is logged in, False otherwise
"""
return 'user_id' in session
def get_user_id():
"""
Get the current user's ID from the session.
Returns:
int: The user ID if logged in, None otherwise
"""
return session.get('user_id')
def get_user_current_bank_id():
"""
Get the current user's selected question bank ID.
Returns:
int: The current question bank ID, defaults to 1
"""
user_id = get_user_id()
if not user_id:
return 1
conn = get_db()
c = conn.cursor()
c.execute('SELECT current_bank_id FROM users WHERE id = ?', (user_id,))
result = c.fetchone()
conn.close()
return result['current_bank_id'] if result and result['current_bank_id'] else 1
##############################
# Question Helper Functions #
##############################
def fetch_question(qid):
"""
Fetch a question by ID from the database.
Args:
qid (str): The question ID
Returns:
dict: The question data or None if not found
"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM questions WHERE id=?', (qid,))
row = c.fetchone()
conn.close()
if row:
return {
'id': row['id'],
'stem': row['stem'],
'answer': row['answer'],
'difficulty': row['difficulty'],
'type': row['qtype'],
'category': row['category'],
'options': json.loads(row['options'])
}
return None
def random_question_id(user_id, bank_id=None):
"""
Get a random question ID for a user, excluding questions they've already answered.
Args:
user_id (int): The user ID
bank_id (int): The question bank ID to filter by
Returns:
str: A random question ID or None if all questions have been answered
"""
if bank_id is None:
bank_id = get_user_current_bank_id()
conn = get_db()
c = conn.cursor()
c.execute('''
SELECT id FROM questions
WHERE bank_id = ? AND id NOT IN (
SELECT question_id FROM history WHERE user_id=?
)
ORDER BY RANDOM()
LIMIT 1
''', (bank_id, user_id))
row = c.fetchone()
conn.close()
if row:
return row['id']
return None
def fetch_random_question_ids(num, bank_id=None):
"""
Fetch multiple random question IDs.
Args:
num (int): The number of question IDs to fetch
bank_id (int): The question bank ID to filter by
Returns:
list: A list of random question IDs
"""
if bank_id is None:
bank_id = get_user_current_bank_id()
conn = get_db()
c = conn.cursor()
c.execute('SELECT id FROM questions WHERE bank_id = ? ORDER BY RANDOM() LIMIT ?', (bank_id, num))
rows = c.fetchall()
conn.close()
return [r['id'] for r in rows]
def is_favorite(user_id, question_id):
"""
Check if a question is favorited by a user.
Args:
user_id (int): The user ID
question_id (str): The question ID
Returns:
bool: True if favorited, False otherwise
"""
conn = get_db()
c = conn.cursor()
c.execute('SELECT 1 FROM favorites WHERE user_id=? AND question_id=?',
(user_id, question_id))
is_fav = bool(c.fetchone())
conn.close()
return is_fav
##############################
# Authentication Routes #
##############################
@app.route('/register', methods=['GET', 'POST'])
def register():
"""Route for user registration."""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
# Input validation
if not username or not password:
flash("用户名和密码不能为空", "error")
return render_template('register.html')
if password != confirm_password:
flash("两次输入的密码不一致", "error")
return render_template('register.html')
if len(password) < 6:
flash("密码长度不能少于6个字符", "error")
return render_template('register.html')
conn = get_db()
c = conn.cursor()
# Check if username exists
c.execute('SELECT id FROM users WHERE username=?', (username,))
if c.fetchone():
conn.close()
flash("用户名已存在,请更换用户名", "error")
return render_template('register.html')
# Create new user
password_hash = generate_password_hash(password)
c.execute('INSERT INTO users (username, password_hash) VALUES (?,?)',
(username, password_hash))
conn.commit()
conn.close()
flash("注册成功,请登录", "success")
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Route for user login."""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash("用户名和密码不能为空", "error")
return render_template('login.html')
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, password_hash FROM users WHERE username=?', (username,))
user = c.fetchone()
conn.close()
if user and check_password_hash(user['password_hash'], password):
session['user_id'] = user['id']
# Redirect to 'next' parameter if provided
next_page = request.args.get('next')
if next_page and next_page.startswith('/'):
return redirect(next_page)
return redirect(url_for('index'))
else:
flash("登录失败,用户名或密码错误", "error")
return render_template('login.html')
@app.route('/logout')
def logout():
"""Route for user logout."""
session.clear()
flash("您已成功退出登录", "success")
return redirect(url_for('login'))
##############################
# User Settings Routes #
##############################
@app.route('/settings', methods=['GET', 'POST'])
@login_required
def user_settings():
"""Route for user settings page."""
user_id = get_user_id()
if request.method == 'POST':
auto_jump_next = 1 if request.form.get('auto_jump_next') else 0
conn = get_db()
c = conn.cursor()
c.execute('UPDATE users SET auto_jump_next = ? WHERE id = ?',
(auto_jump_next, user_id))
conn.commit()
conn.close()
flash("设置已保存", "success")
return redirect(url_for('user_settings'))
# GET request - load current settings
conn = get_db()
c = conn.cursor()
c.execute('SELECT auto_jump_next FROM users WHERE id = ?', (user_id,))
user_data = c.fetchone()
conn.close()
auto_jump_next = user_data['auto_jump_next'] if user_data else 0
return render_template('settings.html', auto_jump_next=auto_jump_next)
@app.route('/toggle_auto_jump', methods=['POST'])
@login_required
def toggle_auto_jump():
"""AJAX route to toggle auto-jump setting."""
user_id = get_user_id()
try:
# Get the new setting from JSON request
data = request.get_json()
if data is None:
return jsonify({'success': False, 'message': '无效的请求数据'})
auto_jump = data.get('auto_jump', False)
auto_jump_value = 1 if auto_jump else 0
# Update user setting in database
conn = get_db()
c = conn.cursor()
c.execute('UPDATE users SET auto_jump_next = ? WHERE id = ?',
(auto_jump_value, user_id))
conn.commit()
conn.close()
message = "自动跳转已开启" if auto_jump else "自动跳转已关闭"
return jsonify({
'success': True,
'auto_jump_enabled': auto_jump,
'message': message
})
except Exception as e:
return jsonify({
'success': False,
'message': f'设置更新失败: {str(e)}'
})
##############################
# Question Bank Management Routes #
##############################
@app.route('/question_bank_management')
@login_required
def question_bank_management():
"""Route for question bank management page."""
page = request.args.get('page', 1, type=int)
per_page = 20
conn = get_db()
c = conn.cursor()
# Get total count
c.execute('SELECT COUNT(*) as total FROM questions')
total = c.fetchone()['total']
# Get questions with pagination
offset = (page - 1) * per_page
c.execute('''
SELECT id, stem, answer, difficulty, qtype, category
FROM questions
ORDER BY CAST(id AS INTEGER) ASC
LIMIT ? OFFSET ?
''', (per_page, offset))
questions = c.fetchall()
conn.close()
# Calculate pagination info
total_pages = (total + per_page - 1) // per_page
return render_template('question_bank_management.html',
questions=questions,
page=page,
total_pages=total_pages,
total=total)
@app.route('/add_question', methods=['GET', 'POST'])
@login_required
def add_question():
"""Route to add a new question."""
if request.method == 'POST':
question_id = request.form.get('question_id')
stem = request.form.get('stem')
answer = request.form.get('answer')
difficulty = request.form.get('difficulty')
qtype = request.form.get('qtype')
category = request.form.get('category')
# Build options
options = {}
for opt in ['A', 'B', 'C', 'D', 'E']:
opt_value = request.form.get(f'option_{opt}')
if opt_value and opt_value.strip():
options[opt] = opt_value.strip()
# Validate required fields
if not all([question_id, stem, answer]):
flash("题号、题干和答案为必填项", "error")
return render_template('add_question.html')
conn = get_db()
c = conn.cursor()
# Check if question ID already exists
c.execute('SELECT id FROM questions WHERE id = ?', (question_id,))
if c.fetchone():
conn.close()
flash("题号已存在,请使用不同的题号", "error")
return render_template('add_question.html')
try:
c.execute('''
INSERT INTO questions (id, stem, answer, difficulty, qtype, category, options)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (question_id, stem, answer, difficulty, qtype, category,
json.dumps(options, ensure_ascii=False)))
conn.commit()
flash("题目添加成功", "success")
return redirect(url_for('question_bank_management'))
except Exception as e:
flash(f"添加题目失败: {str(e)}", "error")
finally:
conn.close()
return render_template('add_question.html')
@app.route('/edit_question/<qid>', methods=['GET', 'POST'])
@login_required
def edit_question(qid):
"""Route to edit an existing question."""
conn = get_db()
c = conn.cursor()
if request.method == 'POST':
stem = request.form.get('stem')
answer = request.form.get('answer')
difficulty = request.form.get('difficulty')
qtype = request.form.get('qtype')
category = request.form.get('category')
# Build options
options = {}
for opt in ['A', 'B', 'C', 'D', 'E']:
opt_value = request.form.get(f'option_{opt}')
if opt_value and opt_value.strip():
options[opt] = opt_value.strip()
# Validate required fields
if not all([stem, answer]):
flash("题干和答案为必填项", "error")
return redirect(url_for('edit_question', qid=qid))
try:
c.execute('''
UPDATE questions
SET stem = ?, answer = ?, difficulty = ?, qtype = ?, category = ?, options = ?
WHERE id = ?
''', (stem, answer, difficulty, qtype, category,
json.dumps(options, ensure_ascii=False), qid))
conn.commit()
flash("题目更新成功", "success")
return redirect(url_for('question_bank_management'))
except Exception as e:
flash(f"更新题目失败: {str(e)}", "error")
finally:
conn.close()
# GET request - load existing question
c.execute('SELECT * FROM questions WHERE id = ?', (qid,))
question = c.fetchone()
conn.close()
if not question:
flash("题目不存在", "error")
return redirect(url_for('question_bank_management'))
# Parse options
options = json.loads(question['options']) if question['options'] else {}
return render_template('edit_question.html', question=question, options=options)
@app.route('/delete_question/<qid>', methods=['POST'])
@login_required
def delete_question(qid):
"""Route to delete a question."""
conn = get_db()
c = conn.cursor()
try:
# Delete from questions table
c.execute('DELETE FROM questions WHERE id = ?', (qid,))
# Delete from history table
c.execute('DELETE FROM history WHERE question_id = ?', (qid,))
# Delete from favorites table
c.execute('DELETE FROM favorites WHERE question_id = ?', (qid,))
conn.commit()
flash("题目删除成功", "success")
except Exception as e:
flash(f"删除题目失败: {str(e)}", "error")
finally:
conn.close()
return redirect(url_for('question_bank_management'))
@app.route('/import_questions', methods=['GET', 'POST'])
@login_required
def import_questions():
"""Route to import questions from CSV file."""
if request.method == 'POST':
if 'csv_file' not in request.files:
flash("请选择CSV文件", "error")
return redirect(url_for('import_questions'))
file = request.files['csv_file']
if file.filename == '':
flash("请选择CSV文件", "error")
return redirect(url_for('import_questions'))
if not file.filename.lower().endswith('.csv'):
flash("请上传CSV格式的文件", "error")
return redirect(url_for('import_questions'))
bank_name = request.form.get('bank_name', '').strip()
if not bank_name:
bank_name = file.filename.rsplit('.', 1)[0] # 使用文件名作为题库名
try:
# Read CSV content
csv_content = file.read().decode('utf-8-sig')
csv_reader = csv.DictReader(csv_content.splitlines())
conn = get_db()
c = conn.cursor()
# Create new question bank
c.execute('''
INSERT INTO question_banks (name, description, filename)
VALUES (?, ?, ?)
''', (bank_name, f"从 {file.filename} 导入的题库", file.filename))
bank_id = c.lastrowid
imported_count = 0
skipped_count = 0
for row in csv_reader:
question_id = row.get("题号")
if not question_id:
continue
# Build unique question ID by combining bank_id and original question_id
unique_question_id = f"{bank_id}_{question_id}"
# Check if question already exists in this bank
c.execute('SELECT id FROM questions WHERE id = ?', (unique_question_id,))
if c.fetchone():
skipped_count += 1
continue
# Build options
options = {}
for opt in ['A', 'B', 'C', 'D', 'E']:
if row.get(opt) and row[opt].strip():
options[opt] = row[opt]
c.execute('''
INSERT INTO questions (id, stem, answer, difficulty, qtype, category, options, bank_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (
unique_question_id,
row.get("题干", ""),
row.get("答案", ""),
row.get("难度", ""),
row.get("题型", ""),
row.get("类别", "未分类"),
json.dumps(options, ensure_ascii=False),
bank_id
))
imported_count += 1
# Update question count for the bank
c.execute('UPDATE question_banks SET question_count = ? WHERE id = ?',
(imported_count, bank_id))
conn.commit()
conn.close()
flash(f"导入完成!创建题库 '{bank_name}',成功导入 {imported_count} 题,跳过 {skipped_count} 题(已存在)", "success")
return redirect(url_for('question_bank_list'))
except Exception as e:
flash(f"导入失败: {str(e)}", "error")
return render_template('import_questions.html')
@app.route('/question_banks')
@login_required
def question_bank_list():
"""Route to display list of question banks."""
conn = get_db()
c = conn.cursor()
# Get all question banks with their question counts
c.execute('''
SELECT qb.*,
COALESCE(COUNT(q.id), 0) as actual_question_count
FROM question_banks qb
LEFT JOIN questions q ON qb.id = q.bank_id
GROUP BY qb.id
ORDER BY qb.created_at DESC
''')
banks = c.fetchall()
# Get current user's selected bank
user_id = get_user_id()
c.execute('SELECT current_bank_id FROM users WHERE id = ?', (user_id,))
current_bank_id = c.fetchone()['current_bank_id']
conn.close()
return render_template('question_banks.html',
banks=banks,
current_bank_id=current_bank_id)
@app.route('/select_bank/<int:bank_id>', methods=['POST'])
@login_required
def select_bank(bank_id):
"""Route to select a question bank for the user."""
user_id = get_user_id()
# Verify bank exists
conn = get_db()
c = conn.cursor()
c.execute('SELECT id, name FROM question_banks WHERE id = ?', (bank_id,))
bank = c.fetchone()
if not bank:
flash("题库不存在", "error")
return redirect(url_for('question_bank_list'))
# Update user's current bank
c.execute('UPDATE users SET current_bank_id = ? WHERE id = ?',
(bank_id, user_id))
conn.commit()
conn.close()
flash(f"已切换到题库:{bank['name']}", "success")
return redirect(url_for('index'))
@app.route('/delete_bank/<int:bank_id>', methods=['POST'])
@login_required
def delete_bank(bank_id):
"""Route to delete a question bank."""
# Don't allow deletion of default bank (id=1)
if bank_id == 1:
flash("无法删除默认题库", "error")
return redirect(url_for('question_bank_list'))
conn = get_db()
c = conn.cursor()
# Check if bank exists
c.execute('SELECT name FROM question_banks WHERE id = ?', (bank_id,))
bank = c.fetchone()
if not bank:
flash("题库不存在", "error")
return redirect(url_for('question_bank_list'))
try:
# Delete all questions in this bank
c.execute('DELETE FROM questions WHERE bank_id = ?', (bank_id,))
# Delete the bank
c.execute('DELETE FROM question_banks WHERE id = ?', (bank_id,))
# Reset users who were using this bank to default bank
c.execute('UPDATE users SET current_bank_id = 1 WHERE current_bank_id = ?', (bank_id,))
conn.commit()
flash(f"题库 '{bank['name']}' 已删除", "success")
except Exception as e:
conn.rollback()
flash(f"删除题库失败: {str(e)}", "error")
finally:
conn.close()
return redirect(url_for('question_bank_list'))
@app.route('/export_questions')
@login_required
def export_questions():
"""Route to export questions to CSV file."""
try:
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM questions ORDER BY CAST(id AS INTEGER) ASC')
questions = c.fetchall()
conn.close()
# Create CSV content
output = []
fieldnames = ['题号', '题干', '答案', '难度', '题型', '类别', 'A', 'B', 'C', 'D', 'E']
# Add header
output.append(','.join(fieldnames))
# Add data rows
for question in questions:
options = json.loads(question['options']) if question['options'] else {}
row = [
f'"{question["id"]}"',
f'"{question["stem"]}"',
f'"{question["answer"]}"',
f'"{question["difficulty"] or ""}"',
f'"{question["qtype"] or ""}"',
f'"{question["category"] or ""}"',
f'"{options.get("A", "")}"',
f'"{options.get("B", "")}"',
f'"{options.get("C", "")}"',
f'"{options.get("D", "")}"',
f'"{options.get("E", "")}"'
]
output.append(','.join(row))
# Create temporary file
import tempfile
import os
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False, encoding='utf-8-sig') as f:
f.write('\n'.join(output))
temp_path = f.name
return send_file(temp_path,
as_attachment=True,
download_name=f'questions_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv',
mimetype='text/csv')
except Exception as e:
flash(f"导出失败: {str(e)}", "error")
return redirect(url_for('question_bank_management'))
##############################
# Main Application Routes #
##############################
@app.route('/')
@login_required
def index():
"""Home page route."""
# Fetch current sequential question ID if exists
user_id = get_user_id()
bank_id = get_user_current_bank_id()
conn = get_db()
c = conn.cursor()
c.execute('SELECT current_seq_qid FROM users WHERE id = ?', (user_id,))
user_data = c.fetchone()
current_seq_qid = user_data['current_seq_qid'] if user_data and user_data['current_seq_qid'] else None
# Get current bank info
c.execute('SELECT name FROM question_banks WHERE id = ?', (bank_id,))
bank_info = c.fetchone()
current_bank_name = bank_info['name'] if bank_info else '未知题库'
# Get question count for current bank
c.execute('SELECT COUNT(*) as total FROM questions WHERE bank_id = ?', (bank_id,))
total_questions = c.fetchone()['total']
conn.close()
return render_template('index.html',
current_year=datetime.now().year,
current_seq_qid=current_seq_qid,
current_bank_name=current_bank_name,
total_questions=total_questions)
@app.route('/reset_history', methods=['POST'])
@login_required
def reset_history():
"""Route to reset a user's answer history."""
user_id = get_user_id()
try:
conn = get_db()
c = conn.cursor()
c.execute('DELETE FROM history WHERE user_id=?', (user_id,))
# Also clear the current sequential question ID
c.execute('UPDATE users SET current_seq_qid = NULL WHERE id = ?', (user_id,))
conn.commit()
conn.close()
flash("答题历史已重置。现在您可以重新开始答题。", "success")
except Exception as e:
flash(f"重置历史时出错: {str(e)}", "error")
return redirect(url_for('random_question'))
##############################
# Question Routes #
##############################
@app.route('/random', methods=['GET'])
@login_required
def random_question():
"""Route to get a random question."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
qid = random_question_id(user_id, bank_id)
conn = get_db()
c = conn.cursor()
# Get total questions count for current bank
c.execute('SELECT COUNT(*) as total FROM questions WHERE bank_id = ?', (bank_id,))
total = c.fetchone()['total']
# Get answered questions count for current bank
c.execute('''SELECT COUNT(DISTINCT h.question_id) as answered
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?''', (user_id, bank_id))
answered = c.fetchone()['answered']
conn.close()
if not qid:
flash("您已完成当前题库的所有题目!可以重置历史以重新开始,或切换到其他题库。", "info")
return render_template('question.html', question=None, answered=answered, total=total)
q = fetch_question(qid)
is_fav = is_favorite(user_id, qid)
# Get user's auto-jump setting
conn = get_db()
c = conn.cursor()
c.execute('SELECT auto_jump_next FROM users WHERE id = ?', (user_id,))
user_settings = c.fetchone()
auto_jump_enabled = user_settings['auto_jump_next'] if user_settings else 0
conn.close()
return render_template('question.html',
question=q,
answered=answered,
total=total,
is_favorite=is_fav,
auto_jump_enabled=auto_jump_enabled)
@app.route('/question/<qid>', methods=['GET', 'POST'])
@login_required
def show_question(qid):
"""Route to view and answer a specific question."""
user_id = get_user_id()
q = fetch_question(qid)
if q is None:
flash("题目不存在", "error")
return redirect(url_for('index'))
# Update last browsed question when viewing any question
conn = get_db()
c = conn.cursor()
c.execute('UPDATE users SET current_seq_qid = ? WHERE id = ?', (qid, user_id))
conn.commit()
# Handle form submission (answer)
if request.method == 'POST':
user_answer = request.form.getlist('answer')
user_answer_str = "".join(sorted(user_answer))
correct = int(user_answer_str == "".join(sorted(q['answer'])))
# Save answer to history
c.execute(
'INSERT INTO history (user_id, question_id, user_answer, correct) VALUES (?,?,?,?)',
(user_id, qid, user_answer_str, correct)
)
conn.commit()
# Check if user has auto-jump enabled and answer is correct
c.execute('SELECT auto_jump_next FROM users WHERE id = ?', (user_id,))
user_settings = c.fetchone()
auto_jump_next = user_settings['auto_jump_next'] if user_settings else 0
# Get current bank_id for auto-jump
bank_id = get_user_current_bank_id()
if correct and auto_jump_next:
# Auto-jump to next random question
next_qid = random_question_id(user_id, bank_id)
if next_qid:
# Get updated stats for current bank before jumping
bank_id = get_user_current_bank_id()
c.execute('SELECT COUNT(*) AS total FROM questions WHERE bank_id = ?', (bank_id,))
total = c.fetchone()['total']
c.execute('''SELECT COUNT(DISTINCT h.question_id) AS answered
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?''', (user_id, bank_id))
answered = c.fetchone()['answered']
conn.close()
# 不使用flash,而是通过模板变量显示状态图标
return render_template('question.html',
question=fetch_question(next_qid),
auto_jump_success=True,
answered=answered,
total=total,
is_favorite=is_favorite(user_id, next_qid),
auto_jump_enabled=auto_jump_next)
# Get updated stats for current bank
bank_id = get_user_current_bank_id()
c.execute('SELECT COUNT(*) AS total FROM questions WHERE bank_id = ?', (bank_id,))
total = c.fetchone()['total']
c.execute('''SELECT COUNT(DISTINCT h.question_id) AS answered
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?''', (user_id, bank_id))
answered = c.fetchone()['answered']
if correct:
result_msg = "回答正确"
# 不使用flash显示正确消息,只在卡片内显示
else:
result_msg = f"回答错误,正确答案:{q['answer']} 你的选项是:{user_answer_str}"
# 不使用flash显示错误消息,只在卡片内显示
is_fav = is_favorite(user_id, qid)
# Get user's auto-jump setting for display
conn2 = get_db()
c2 = conn2.cursor()
c2.execute('SELECT auto_jump_next FROM users WHERE id = ?', (user_id,))
user_settings = c2.fetchone()
auto_jump_enabled = user_settings['auto_jump_next'] if user_settings else 0
conn2.close()
conn.close()
return render_template('question.html',
question=q,
result_msg=result_msg,
user_answer=user_answer_str,
answered=answered,
total=total,
is_favorite=is_fav,
auto_jump_enabled=auto_jump_enabled)
# Handle GET request
bank_id = get_user_current_bank_id()
c.execute('SELECT COUNT(*) AS total FROM questions WHERE bank_id = ?', (bank_id,))
total = c.fetchone()['total']
c.execute('''SELECT COUNT(DISTINCT h.question_id) AS answered
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?''', (user_id, bank_id))
answered = c.fetchone()['answered']
# Get user's auto-jump setting
c.execute('SELECT auto_jump_next FROM users WHERE id = ?', (user_id,))
user_settings = c.fetchone()
auto_jump_enabled = user_settings['auto_jump_next'] if user_settings else 0
conn.close()
is_fav = is_favorite(user_id, qid)
return render_template('question.html',
question=q,
answered=answered,
total=total,
is_favorite=is_fav,
auto_jump_enabled=auto_jump_enabled)
@app.route('/history')
@login_required
def show_history():
"""Route to view answer history."""
user_id = get_user_id()
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM history WHERE user_id=? ORDER BY timestamp DESC', (user_id,))
rows = c.fetchall()
conn.close()
history_data = []
for r in rows:
q = fetch_question(r['question_id'])
stem = q['stem'] if q else '题目已删除'
history_data.append({
'id': r['id'],
'question_id': r['question_id'],
'stem': stem,
'user_answer': r['user_answer'],
'correct': r['correct'],
'timestamp': r['timestamp']
})
return render_template('history.html', history=history_data)
@app.route('/search', methods=['GET', 'POST'])
@login_required
def search():
"""Route to search for questions by keyword."""
query = request.form.get('query', '')
results = []
if query:
conn = get_db()
c = conn.cursor()
c.execute("SELECT * FROM questions WHERE stem LIKE ?", ('%'+query+'%',))
rows = c.fetchall()
conn.close()
for row in rows:
q = {
'id': row['id'],
'stem': row['stem']
}
results.append(q)
return render_template('search.html', query=query, results=results)
@app.route('/wrong')
@login_required
def wrong_questions():
"""Route to view wrong answers with enhanced statistics."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
conn = get_db()
c = conn.cursor()
# Get wrong questions with detailed statistics
c.execute('''
SELECT
h.question_id,
COUNT(*) as wrong_count,
MAX(h.timestamp) as last_wrong_time,
q.stem, q.answer, q.difficulty, q.qtype, q.category, q.options
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND h.correct = 0 AND q.bank_id = ?
GROUP BY h.question_id
ORDER BY last_wrong_time DESC
''', (user_id, bank_id))
rows = c.fetchall()
# Get practice statistics for wrong questions
wrong_question_ids = [r['question_id'] for r in rows]
practiced_count = 0
if wrong_question_ids:
placeholders = ','.join(['?' for _ in wrong_question_ids])
c.execute(f'''
SELECT COUNT(DISTINCT question_id) as practiced
FROM history
WHERE user_id = ? AND correct = 1
AND question_id IN ({placeholders})
AND timestamp > (
SELECT MAX(timestamp)
FROM history h2
WHERE h2.user_id = ? AND h2.correct = 0
AND h2.question_id = history.question_id
)
''', [user_id] + wrong_question_ids + [user_id])
result = c.fetchone()
practiced_count = result['practiced'] if result else 0
conn.close()
questions_list = []
for row in rows:
question_data = {
'id': row['question_id'],
'stem': row['stem'],
'answer': row['answer'],
'difficulty': row['difficulty'],
'type': row['qtype'],
'category': row['category'],
'options': json.loads(row['options']) if row['options'] else {},
'wrong_count': row['wrong_count'],
'last_wrong_time': row['last_wrong_time']
}
questions_list.append(question_data)
# Calculate statistics
wrong_stats = {
'total': len(questions_list),
'practiced': practiced_count,
'remaining': len(questions_list) - practiced_count
}
return render_template('wrong.html', questions=questions_list, wrong_stats=wrong_stats)
@app.route('/only_wrong')
@login_required
def only_wrong_mode():
"""Route to practice only wrong questions."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
conn = get_db()
c = conn.cursor()
c.execute('''SELECT DISTINCT h.question_id
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND h.correct = 0 AND q.bank_id = ?''',
(user_id, bank_id))
rows = c.fetchall()
conn.close()
wrong_ids = [r['question_id'] for r in rows]
if not wrong_ids:
flash("当前题库中没有错题或还未答题", "info")
return redirect(url_for('index'))
qid = random.choice(wrong_ids)
q = fetch_question(qid)
is_fav = is_favorite(user_id, qid)
# Get user's auto-jump setting
conn = get_db()
c = conn.cursor()
c.execute('SELECT auto_jump_next FROM users WHERE id = ?', (user_id,))
user_settings = c.fetchone()
auto_jump_enabled = user_settings['auto_jump_next'] if user_settings else 0
conn.close()
return render_template('question.html',
question=q,
is_favorite=is_fav,
auto_jump_enabled=auto_jump_enabled)
##############################
# Wrong Questions Practice Routes #
##############################
@app.route('/start_wrong_practice', methods=['GET', 'POST'])
@login_required
def start_wrong_practice():
"""Route to start wrong questions practice with options."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
if request.method == 'POST':
practice_mode = request.form.get('practice_mode', 'random')
practice_count = request.form.get('practice_count', 'all')
only_unpracticed = request.form.get('only_unpracticed') == 'on'
# Store practice session in session
session['wrong_practice'] = {
'mode': practice_mode,
'count': practice_count,
'only_unpracticed': only_unpracticed,
'current_index': 0,
'questions': []
}
return redirect(url_for('wrong_practice_session'))
# GET request - redirect to random practice
return redirect(url_for('only_wrong_mode'))
@app.route('/wrong_practice_session')
@login_required
def wrong_practice_session():
"""Route to handle wrong questions practice session."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
# Get practice session from session
practice_session = session.get('wrong_practice')
if not practice_session:
flash("练习会话已过期,请重新开始", "warning")
return redirect(url_for('wrong_questions'))
conn = get_db()
c = conn.cursor()
# Get wrong questions based on practice options
if practice_session['only_unpracticed']:
# Only get questions that haven't been answered correctly after being wrong
c.execute('''
SELECT DISTINCT h.question_id, q.stem, q.answer, q.difficulty, q.qtype, q.category, q.options
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND h.correct = 0 AND q.bank_id = ?
AND h.question_id NOT IN (
SELECT h2.question_id
FROM history h2
WHERE h2.user_id = ? AND h2.correct = 1
AND h2.timestamp > (
SELECT MAX(h3.timestamp)
FROM history h3
WHERE h3.user_id = ? AND h3.correct = 0
AND h3.question_id = h2.question_id
)
)
''', (user_id, bank_id, user_id, user_id))
else:
# Get all wrong questions
c.execute('''
SELECT DISTINCT h.question_id, q.stem, q.answer, q.difficulty, q.qtype, q.category, q.options
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND h.correct = 0 AND q.bank_id = ?
''', (user_id, bank_id))
rows = c.fetchall()
conn.close()
if not rows:
flash("没有符合条件的错题", "info")
return redirect(url_for('wrong_questions'))
# Prepare questions list
questions_list = []
for row in rows:
question_data = {
'id': row['question_id'],
'stem': row['stem'],
'answer': row['answer'],
'difficulty': row['difficulty'],
'type': row['qtype'],
'category': row['category'],
'options': json.loads(row['options']) if row['options'] else {}
}
questions_list.append(question_data)
# Apply sorting based on practice mode
if practice_session['mode'] == 'random':
random.shuffle(questions_list)
elif practice_session['mode'] == 'sequential':
questions_list.sort(key=lambda x: int(x['id']))
elif practice_session['mode'] == 'difficulty':
# Sort by difficulty: 简单 -> 中等 -> 困难
difficulty_order = {'简单': 1, '中等': 2, '困难': 3}
questions_list.sort(key=lambda x: difficulty_order.get(x['difficulty'], 4))
# Apply count limit
if practice_session['count'] != 'all':
count_limit = int(practice_session['count'])
questions_list = questions_list[:count_limit]
# Update session with questions
practice_session['questions'] = [q['id'] for q in questions_list]
practice_session['total_questions'] = len(questions_list)
session['wrong_practice'] = practice_session
if not questions_list:
flash("没有符合条件的错题", "info")
return redirect(url_for('wrong_questions'))
# Redirect to first question
first_qid = questions_list[0]['id']
return redirect(url_for('wrong_practice_question', qid=first_qid))
@app.route('/wrong_practice/<qid>', methods=['GET', 'POST'])
@login_required
def wrong_practice_question(qid):
"""Route to show and handle wrong practice questions."""
user_id = get_user_id()
practice_session = session.get('wrong_practice')
if not practice_session or qid not in practice_session['questions']:
flash("练习会话无效", "error")
return redirect(url_for('wrong_questions'))
q = fetch_question(qid)
if q is None:
flash("题目不存在", "error")
return redirect(url_for('wrong_questions'))
current_index = practice_session['questions'].index(qid)
total_questions = practice_session['total_questions']
result_msg = None
user_answer_str = ""
next_qid = None
# Handle POST request (user submitted an answer)
if request.method == 'POST':
user_answer = request.form.getlist('answer')
user_answer_str = "".join(sorted(user_answer))
correct = int(user_answer_str == "".join(sorted(q['answer'])))
# Save answer to history
conn = get_db()
c = conn.cursor()
c.execute('INSERT INTO history (user_id, question_id, user_answer, correct) '
'VALUES (?,?,?,?)',
(user_id, qid, user_answer_str, correct))
conn.commit()
conn.close()
# Update practice session progress
practice_session['current_index'] = current_index + 1
session['wrong_practice'] = practice_session
# Find next question
if current_index + 1 < total_questions:
next_qid = practice_session['questions'][current_index + 1]
if correct:
result_msg = "回答正确!"
if next_qid:
result_msg += f" 继续下一题 ({current_index + 2}/{total_questions})"
else:
result_msg += " 错题练习完成!"
else:
result_msg = f"回答错误,正确答案:{q['answer']},你的答案:{user_answer_str}"
if next_qid:
result_msg += f" 继续下一题 ({current_index + 2}/{total_questions})"
is_fav = is_favorite(user_id, qid)
return render_template('question.html',
question=q,
result_msg=result_msg,
next_qid=next_qid,
wrong_practice_mode=True,
user_answer=user_answer_str,
current_question=current_index + 1,
total_questions=total_questions,
is_favorite=is_fav)
@app.route('/wrong_practice_sequential')
@login_required
def wrong_practice_sequential():
"""Route to start sequential wrong questions practice."""
session['wrong_practice'] = {
'mode': 'sequential',
'count': 'all',
'only_unpracticed': False,
'current_index': 0,
'questions': []
}
return redirect(url_for('wrong_practice_session'))
@app.route('/practice_single_wrong/<qid>')
@login_required
def practice_single_wrong(qid):
"""Route to practice a single wrong question."""
user_id = get_user_id()
q = fetch_question(qid)
if q is None:
flash("题目不存在", "error")
return redirect(url_for('wrong_questions'))
# Check if this is actually a wrong question for the user
conn = get_db()
c = conn.cursor()
c.execute('SELECT 1 FROM history WHERE user_id = ? AND question_id = ? AND correct = 0',
(user_id, qid))
if not c.fetchone():
flash("这不是您的错题", "warning")
conn.close()
return redirect(url_for('wrong_questions'))
conn.close()
is_fav = is_favorite(user_id, qid)
return render_template('question.html',
question=q,
single_wrong_practice=True,
is_favorite=is_fav)
@app.route('/mark_wrong_as_mastered/<qid>', methods=['POST'])
@login_required
def mark_wrong_as_mastered(qid):
"""API route to mark a wrong question as mastered."""
user_id = get_user_id()
try:
# Add a correct answer to history to mark as mastered
conn = get_db()
c = conn.cursor()
# Get the correct answer for this question
q = fetch_question(qid)
if not q:
return jsonify({"success": False, "message": "题目不存在"})
# Insert a correct answer with a special marker
c.execute('''INSERT INTO history (user_id, question_id, user_answer, correct, timestamp)
VALUES (?,?,?,1,?)''',
(user_id, qid, q['answer'], datetime.now().isoformat()))
conn.commit()
conn.close()
return jsonify({"success": True, "message": "已标记为掌握"})
except Exception as e:
return jsonify({"success": False, "message": f"操作失败: {str(e)}"})
##############################
# Browse Routes #
##############################
@app.route('/browse')
@login_required
def browse_questions():
"""Route to browse all questions."""
user_id = get_user_id()
page = request.args.get('page', 1, type=int)
question_type = request.args.get('type', '')
search_query = request.args.get('search', '')
per_page = 20 # Questions per page
conn = get_db()
c = conn.cursor()
# Build SQL query with filters
where_conditions = []
params = []
if question_type and question_type != 'all':
where_conditions.append('qtype = ?')
params.append(question_type)
if search_query:
where_conditions.append('(stem LIKE ? OR id LIKE ?)')
params.extend(['%' + search_query + '%', '%' + search_query + '%'])
where_clause = ' WHERE ' + ' AND '.join(where_conditions) if where_conditions else ''
# Get total count with filters
count_sql = f'SELECT COUNT(*) as total FROM questions{where_clause}'
c.execute(count_sql, params)
total = c.fetchone()['total']
# Get questions with pagination and filters
offset = (page - 1) * per_page
query_params = params + [per_page, offset]
c.execute(f'''
SELECT id, stem, answer, difficulty, qtype, category, options
FROM questions
{where_clause}
ORDER BY CAST(id AS INTEGER) ASC
LIMIT ? OFFSET ?
''', query_params)
rows = c.fetchall()
questions = []
for row in rows:
question_data = {
'id': row['id'],
'stem': row['stem'],
'answer': row['answer'],
'difficulty': row['difficulty'],
'type': row['qtype'],
'category': row['category'],
'options': json.loads(row['options']) if row['options'] else {}
}
# Check if favorited by current user
c.execute('SELECT 1 FROM favorites WHERE user_id=? AND question_id=?',
(user_id, row['id']))
question_data['is_favorite'] = bool(c.fetchone())
questions.append(question_data)
# Get available question types for filter chips
c.execute('SELECT DISTINCT qtype FROM questions WHERE qtype IS NOT NULL AND qtype != ""')
available_types = [r['qtype'] for r in c.fetchall()]
conn.close()
# Calculate pagination info
total_pages = (total + per_page - 1) // per_page
has_prev = page > 1
has_next = page < total_pages
return render_template('browse.html',
questions=questions,
total=total,
page=page,
per_page=per_page,
total_pages=total_pages,
has_prev=has_prev,
has_next=has_next,
current_type=question_type,
current_search=search_query,
available_types=available_types)
##############################
# Filter Routes #
##############################
@app.route('/filter', methods=['GET', 'POST'])
@login_required
def filter_questions():
"""Route to filter questions by category and difficulty."""
conn = get_db()
c = conn.cursor()
# Get all categories and difficulties for dropdown selection
c.execute('SELECT DISTINCT category FROM questions WHERE category IS NOT NULL AND category != ""')
categories = [r['category'] for r in c.fetchall()]
c.execute('SELECT DISTINCT difficulty FROM questions WHERE difficulty IS NOT NULL AND difficulty != ""')
difficulties = [r['difficulty'] for r in c.fetchall()]
selected_category = ''
selected_difficulty = ''
results = []
if request.method == 'POST':
selected_category = request.form.get('category', '')
selected_difficulty = request.form.get('difficulty', '')
sql = "SELECT id, stem FROM questions WHERE 1=1"
params = []
if selected_category:
sql += " AND category=?"
params.append(selected_category)
if selected_difficulty:
sql += " AND difficulty=?"
params.append(selected_difficulty)
c.execute(sql, params)
rows = c.fetchall()
for row in rows:
results.append({'id': row['id'], 'stem': row['stem']})
conn.close()
return render_template('filter.html',
categories=categories,
difficulties=difficulties,
selected_category=selected_category,
selected_difficulty=selected_difficulty,
results=results)
##############################
# Favorites Routes #
##############################
@app.route('/favorite/<qid>', methods=['POST'])
@login_required
def favorite_question(qid):
"""Route to add a question to favorites."""
user_id = get_user_id()
conn = get_db()
c = conn.cursor()
try:
c.execute('INSERT OR IGNORE INTO favorites (user_id, question_id, tag) VALUES (?,?,?)',
(user_id, qid, ''))
conn.commit()
flash("收藏成功!", "success")
except Exception as e:
flash(f"收藏失败: {str(e)}", "error")
finally:
conn.close()
# Redirect back to the question page
referrer = request.referrer
if referrer and '/question/' in referrer:
return redirect(referrer)
return redirect(url_for('show_question', qid=qid))
@app.route('/unfavorite/<qid>', methods=['POST'])
@login_required
def unfavorite_question(qid):
"""Route to remove a question from favorites."""
user_id = get_user_id()
conn = get_db()
c = conn.cursor()
try:
c.execute('DELETE FROM favorites WHERE user_id=? AND question_id=?',
(user_id, qid))
conn.commit()
flash("已取消收藏", "success")
except Exception as e:
flash(f"取消收藏失败: {str(e)}", "error")
finally:
conn.close()
# Redirect back to the question page
referrer = request.referrer
if referrer and '/question/' in referrer:
return redirect(referrer)
return redirect(url_for('show_question', qid=qid))
@app.route('/update_tag/<qid>', methods=['POST'])
@login_required
def update_tag(qid):
"""API route to update a favorite's tag."""
if not is_logged_in():
return jsonify({"success": False, "msg": "未登录"}), 401
user_id = get_user_id()
new_tag = request.form.get('tag', '')
conn = get_db()
c = conn.cursor()
try:
c.execute('UPDATE favorites SET tag=? WHERE user_id=? AND question_id=?',
(new_tag, user_id, qid))
conn.commit()
return jsonify({"success": True, "msg": "标记更新成功"})
except Exception as e:
return jsonify({"success": False, "msg": f"更新失败: {str(e)}"}), 500
finally:
conn.close()
@app.route('/favorites')
@login_required
def show_favorites():
"""Route to view favorites."""
user_id = get_user_id()
conn = get_db()
c = conn.cursor()
c.execute('''
SELECT f.question_id, f.tag, q.stem
FROM favorites f
JOIN questions q ON f.question_id=q.id
WHERE f.user_id=?
''', (user_id,))
rows = c.fetchall()
conn.close()
favorites_data = [
{'question_id': r['question_id'], 'tag': r['tag'], 'stem': r['stem']}
for r in rows
]
return render_template('favorites.html', favorites=favorites_data)
##############################
# Sequential Mode Routes #
##############################
@app.route('/sequential_start')
@login_required
def sequential_start():
"""Route to start or continue sequential answering mode."""
user_id = get_user_id()
conn = get_db()
c = conn.cursor()
# Check if user has a saved position
c.execute('SELECT current_seq_qid FROM users WHERE id=?', (user_id,))
user_data = c.fetchone()
if user_data and user_data['current_seq_qid']:
# Continue from last browsed position (start from where user left off)
current_qid = user_data['current_seq_qid']
else:
# Find the first unanswered question in current bank
bank_id = get_user_current_bank_id()
c.execute('''
SELECT id
FROM questions
WHERE bank_id = ? AND id NOT IN (
SELECT h.question_id FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?
)
ORDER BY CAST(id AS INTEGER) ASC
LIMIT 1
''', (bank_id, user_id, bank_id))
row = c.fetchone()
if row is None:
# If all questions in current bank are answered, find the first question in bank
c.execute('''
SELECT id
FROM questions
WHERE bank_id = ?
ORDER BY CAST(id AS INTEGER) ASC
LIMIT 1
''', (bank_id,))
row = c.fetchone()
if row is None:
conn.close()
flash("题库中没有题目!", "error")
return redirect(url_for('index'))
current_qid = row['id']
flash("所有题目已完成,从第一题重新开始。", "info")
else:
current_qid = row['id']
# Save the position
c.execute(
'UPDATE users SET current_seq_qid = ? WHERE id = ?',
(current_qid, user_id)
)
conn.commit()
conn.close()
return redirect(url_for('show_sequential_question', qid=current_qid))
@app.route('/sequential/<qid>', methods=['GET', 'POST'])
@login_required
def show_sequential_question(qid):
"""Route to show and handle sequential questions."""
user_id = get_user_id()
q = fetch_question(qid)
if q is None:
flash("题目不存在", "error")
return redirect(url_for('index'))
next_qid = None
result_msg = None
user_answer_str = ""
conn = get_db()
c = conn.cursor()
# Update current_seq_qid to the current question when viewing it
c.execute('UPDATE users SET current_seq_qid = ? WHERE id = ?', (qid, user_id))
conn.commit()
# Handle POST request (user submitted an answer)
if request.method == 'POST':
user_answer = request.form.getlist('answer')
user_answer_str = "".join(sorted(user_answer))
correct = int(user_answer_str == "".join(sorted(q['answer'])))
# Save answer to history
c.execute('INSERT INTO history (user_id, question_id, user_answer, correct) '
'VALUES (?,?,?,?)',
(user_id, qid, user_answer_str, correct))
# Find next unanswered question with higher ID in current bank
bank_id = get_user_current_bank_id()
c.execute('''
SELECT id FROM questions
WHERE bank_id = ? AND CAST(id AS INTEGER) > ?
AND id NOT IN (
SELECT h.question_id FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?
)
ORDER BY CAST(id AS INTEGER) ASC
LIMIT 1
''', (bank_id, int(qid), user_id, bank_id))
row = c.fetchone()
if row:
next_qid = row['id']
c.execute('UPDATE users SET current_seq_qid = ? WHERE id = ?',
(next_qid, user_id))
else:
# Check if there are any questions left to answer in current bank
c.execute('''
SELECT id FROM questions
WHERE bank_id = ? AND id NOT IN (
SELECT h.question_id FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?
)
ORDER BY CAST(id AS INTEGER) ASC
LIMIT 1
''', (bank_id, user_id, bank_id))
row = c.fetchone()
if row:
next_qid = row['id']
c.execute('UPDATE users SET current_seq_qid = ? WHERE id = ?',
(next_qid, user_id))
else:
# All questions in current bank answered, reset to first question in bank
c.execute('''
SELECT id FROM questions
WHERE bank_id = ?
ORDER BY CAST(id AS INTEGER) ASC
LIMIT 1
''', (bank_id,))
row = c.fetchone()
if row:
next_qid = row['id']
c.execute('UPDATE users SET current_seq_qid = ? WHERE id = ?',
(next_qid, user_id))
flash("当前题库所有题目已完成,从第一题重新开始。", "info")
else:
c.execute('UPDATE users SET current_seq_qid = NULL WHERE id = ?',
(user_id,))
if correct:
result_msg = "回答正确!"
# 不使用flash显示正确消息,只在卡片内显示
else:
result_msg = f"回答错误,正确答案:{q['answer']} 你的选项是:{user_answer_str}"
# 不使用flash显示错误消息,只在卡片内显示
# Get progress statistics for current bank
bank_id = get_user_current_bank_id()
c.execute('SELECT COUNT(*) AS total FROM questions WHERE bank_id = ?', (bank_id,))
total = c.fetchone()['total']
c.execute('''SELECT COUNT(DISTINCT h.question_id) AS answered
FROM history h
JOIN questions q ON h.question_id = q.id
WHERE h.user_id = ? AND q.bank_id = ?''', (user_id, bank_id))
answered = c.fetchone()['answered']
conn.commit()
conn.close()
is_fav = is_favorite(user_id, qid)
return render_template('question.html',
question=q,
result_msg=result_msg,
next_qid=next_qid,
sequential_mode=True,
user_answer=user_answer_str,
answered=answered,
total=total,
is_favorite=is_fav)
##############################
# Timed Mode & Exam Routes #
##############################
@app.route('/modes')
@login_required
def modes():
"""Route to select quiz mode."""
return render_template('index.html', mode_select=True, current_year=datetime.now().year)
@app.route('/start_timed_mode', methods=['POST'])
@login_required
def start_timed_mode():
"""Route to start timed mode quiz."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
# Configuration for timed mode
question_count = int(request.form.get('question_count', 5))
duration_minutes = int(request.form.get('duration', 10))
question_ids = fetch_random_question_ids(question_count, bank_id)
start_time = datetime.now()
duration = duration_minutes * 60 # Convert minutes to seconds
conn = get_db()
c = conn.cursor()
# Check if we have enough questions
if len(question_ids) < question_count:
flash(f"当前题库中只有 {len(question_ids)} 题,无法开始定时模式", "error")
return redirect(url_for('index'))
try:
c.execute('''
INSERT INTO exam_sessions
(user_id, mode, question_ids, start_time, duration)
VALUES (?,?,?,?,?)
''', (user_id, 'timed', json.dumps(question_ids), start_time, duration))
exam_id = c.lastrowid
conn.commit()
session['current_exam_id'] = exam_id
return redirect(url_for('timed_mode'))
except Exception as e:
flash(f"启动定时模式失败: {str(e)}", "error")
return redirect(url_for('index'))
finally:
conn.close()
@app.route('/timed_mode')
@login_required
def timed_mode():
"""Route for timed mode quiz interface."""
user_id = get_user_id()
exam_id = session.get('current_exam_id')
if not exam_id:
flash("未启动定时模式", "error")
return redirect(url_for('index'))
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM exam_sessions WHERE id=? AND user_id=?', (exam_id, user_id))
exam = c.fetchone()
conn.close()
if not exam:
flash("无法找到考试会话", "error")
return redirect(url_for('index'))
question_ids = json.loads(exam['question_ids'])
start_time = datetime.strptime(exam['start_time'], '%Y-%m-%d %H:%M:%S.%f')
end_time = start_time + timedelta(seconds=exam['duration'])
remaining = (end_time - datetime.now()).total_seconds()
if remaining <= 0:
# Time's up, auto-submit
return redirect(url_for('submit_timed_mode'))
questions_list = [fetch_question(qid) for qid in question_ids]
return render_template('timed_mode.html', questions=questions_list, remaining=remaining)
@app.route('/submit_timed_mode', methods=['POST', 'GET'])
@login_required
def submit_timed_mode():
"""Route to submit answers from timed mode."""
user_id = get_user_id()
exam_id = session.get('current_exam_id')
if not exam_id:
flash("没有正在进行的定时模式", "error")
return redirect(url_for('index'))
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM exam_sessions WHERE id=? AND user_id=?', (exam_id, user_id))
exam = c.fetchone()
if not exam:
conn.close()
flash("无法找到考试会话", "error")
return redirect(url_for('index'))
question_ids = json.loads(exam['question_ids'])
# Process answers
correct_count = 0
total = len(question_ids)
for qid in question_ids:
user_answer = request.form.getlist(f'answer_{qid}')
q = fetch_question(qid)
if not q:
continue
user_answer_str = "".join(sorted(user_answer))
correct = 1 if user_answer_str == "".join(sorted(q['answer'])) else 0
if correct:
correct_count += 1
# Save to history
c.execute('INSERT INTO history (user_id, question_id, user_answer, correct) VALUES (?,?,?,?)',
(user_id, qid, user_answer_str, correct))
# Mark session as completed and save score
score = (correct_count / total * 100) if total > 0 else 0
c.execute('UPDATE exam_sessions SET completed=1, score=? WHERE id=?', (score, exam_id))
conn.commit()
conn.close()
# Clear session
session.pop('current_exam_id', None)
flash(f"定时模式结束!正确率:{correct_count}/{total} = {score:.2f}%",
"success" if score >= 60 else "error")
return redirect(url_for('statistics'))
@app.route('/start_exam', methods=['POST'])
@login_required
def start_exam():
"""Route to start exam mode."""
user_id = get_user_id()
bank_id = get_user_current_bank_id()
# Configuration
question_count = int(request.form.get('question_count', 10))
question_ids = fetch_random_question_ids(question_count, bank_id)
start_time = datetime.now()
duration = 0 # 0 means no time limit
# Check if we have enough questions
if len(question_ids) < question_count:
flash(f"当前题库中只有 {len(question_ids)} 题,无法开始模拟考试", "error")
return redirect(url_for('index'))
conn = get_db()
c = conn.cursor()
try:
c.execute('''
INSERT INTO exam_sessions
(user_id, mode, question_ids, start_time, duration)
VALUES (?,?,?,?,?)
''', (user_id, 'exam', json.dumps(question_ids), start_time, duration))
exam_id = c.lastrowid
conn.commit()
session['current_exam_id'] = exam_id
return redirect(url_for('exam'))
except Exception as e:
flash(f"启动模拟考试失败: {str(e)}", "error")
return redirect(url_for('index'))
finally:
conn.close()
@app.route('/exam')
@login_required
def exam():
"""Route for exam mode interface."""
user_id = get_user_id()
exam_id = session.get('current_exam_id')
if not exam_id:
flash("未启动考试模式", "error")
return redirect(url_for('index'))
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM exam_sessions WHERE id=? AND user_id=?', (exam_id, user_id))
exam = c.fetchone()
conn.close()
if not exam:
flash("无法找到考试", "error")
return redirect(url_for('index'))
question_ids = json.loads(exam['question_ids'])
questions_list = [fetch_question(qid) for qid in question_ids]
return render_template('exam.html', questions=questions_list)
@app.route('/submit_exam', methods=['POST'])
@login_required
def submit_exam():
"""Route to submit answers from exam mode."""
user_id = get_user_id()
exam_id = session.get('current_exam_id')
if not exam_id:
return jsonify({
"success": False,
"msg": "没有正在进行的考试"
}), 400
conn = get_db()
c = conn.cursor()
c.execute('SELECT * FROM exam_sessions WHERE id=? AND user_id=?', (exam_id, user_id))
exam = c.fetchone()
if not exam:
conn.close()
return jsonify({
"success": False,
"msg": "无法找到考试"
}), 404
question_ids = json.loads(exam['question_ids'])
# Process answers
correct_count = 0
total = len(question_ids)
question_results = []
for qid in question_ids:
user_answer = request.form.getlist(f'answer_{qid}')
q = fetch_question(qid)
if not q:
continue
user_answer_str = "".join(sorted(user_answer))
correct = 1 if user_answer_str == "".join(sorted(q['answer'])) else 0
if correct:
correct_count += 1
# Save to history
c.execute('INSERT INTO history (user_id, question_id, user_answer, correct) VALUES (?,?,?,?)',
(user_id, qid, user_answer_str, correct))
# Add to results
question_results.append({
"id": qid,
"stem": q['stem'],
"user_answer": user_answer_str,
"correct_answer": q['answer'],
"is_correct": correct == 1
})
# Mark session as completed and save score
score = (correct_count / total * 100) if total > 0 else 0
c.execute('UPDATE exam_sessions SET completed=1, score=? WHERE id=?', (score, exam_id))
conn.commit()
conn.close()
# Clear session
session.pop('current_exam_id', None)
# Return detailed results
return jsonify({
"success": True,
"correct_count": correct_count,
"total": total,
"score": score,
"results": question_results
})
##############################
# Statistics Routes #
##############################
@app.route('/statistics')
@login_required
def statistics():
"""Route to view user statistics."""
user_id = get_user_id()
conn = get_db()
c = conn.cursor()
# Overall accuracy
c.execute('''
SELECT
COUNT(*) as total,
SUM(correct) as correct_count
FROM history
WHERE user_id=?
''', (user_id,))
row = c.fetchone()
total = row['total'] if row['total'] else 0
correct_count = row['correct_count'] if row['correct_count'] else 0
overall_accuracy = (correct_count/total*100) if total>0 else 0
# Stats by difficulty
c.execute('''
SELECT
q.difficulty,
COUNT(*) as total,
SUM(h.correct) as correct_count
FROM history h
JOIN questions q ON h.question_id=q.id
WHERE h.user_id=?
GROUP BY q.difficulty
''', (user_id,))
difficulty_stats = []
for r in c.fetchall():
difficulty_stats.append({
'difficulty': r['difficulty'] or '未分类',
'total': r['total'],
'correct_count': r['correct_count'],
'accuracy': (r['correct_count']/r['total']*100) if r['total']>0 else 0
})
# Stats by category
c.execute('''
SELECT
q.category,
COUNT(*) as total,
SUM(h.correct) as correct_count
FROM history h
JOIN questions q ON h.question_id=q.id
WHERE h.user_id=?
GROUP BY q.category
''', (user_id,))
category_stats = []
for r in c.fetchall():
category_stats.append({
'category': r['category'] or '未分类',
'total': r['total'],
'correct_count': r['correct_count'],
'accuracy': (r['correct_count']/r['total']*100) if r['total']>0 else 0
})
# Most wrong questions
c.execute('''
SELECT
h.question_id,
COUNT(*) as wrong_times,
q.stem
FROM history h
JOIN questions q ON h.question_id=q.id
WHERE h.user_id=? AND h.correct=0
GROUP BY h.question_id
ORDER BY wrong_times DESC
LIMIT 10
''', (user_id,))
worst_questions = []
for r in c.fetchall():
worst_questions.append({
'question_id': r['question_id'],
'stem': r['stem'],
'wrong_times': r['wrong_times']
})
# Recent exams
c.execute('''
SELECT
id,
mode,
start_time,
score,
(SELECT COUNT(*) FROM JSON_EACH(question_ids)) as question_count
FROM exam_sessions
WHERE user_id=? AND completed=1
ORDER BY start_time DESC
LIMIT 5
''', (user_id,))
recent_exams = []
for r in c.fetchall():
recent_exams.append({
'id': r['id'],
'mode': r['mode'],
'start_time': r['start_time'],
'score': r['score'],
'question_count': r['question_count']
})
conn.close()
return render_template('statistics.html',
overall_accuracy=overall_accuracy,
difficulty_stats=difficulty_stats,
category_stats=category_stats,
worst_questions=worst_questions,
recent_exams=recent_exams)
##############################
# APK Download Routes #
##############################
@app.route('/ExamMasterAndroid/<filename>')
def download_apk(filename):
"""Handle APK file downloads."""
try:
# Security check: only allow .apk files
if not filename.endswith('.apk'):
abort(404)
# Use absolute path based on script location
script_dir = os.path.dirname(os.path.abspath(__file__))
apk_path = os.path.join(script_dir, 'ExamMasterAndroid', filename)
# Check if file exists
if not os.path.exists(apk_path):
abort(404)
# Send file with proper headers
return send_file(
apk_path,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.android.package-archive'
)
except Exception as e:
print(f"Error in download_apk: {e}")
abort(404)
##############################
# Error Handlers #
##############################
@app.errorhandler(404)
def page_not_found(e):
"""Handle 404 errors."""
return render_template('error.html',
error_code=404,
error_message="页面不存在"), 404
@app.errorhandler(500)
def server_error(e):
"""Handle 500 errors."""
return render_template('error.html',
error_code=500,
error_message="服务器内部错误"), 500
##############################
# Application Entry Point #
##############################
if __name__ == '__main__':
app.run(host="0.0.0.0", debug=True, port=32220)