378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""学习档案导入 / 导出(对应静态版的导出 / 导入 JSON)。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import date, datetime
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||
from sqlalchemy.orm import Session
|
||
|
||
from database import get_db
|
||
from dependencies import require_learning_user
|
||
from models import (
|
||
AttemptItem,
|
||
AttemptSession,
|
||
DailyCompletion,
|
||
ErrorEntry,
|
||
Knowledge,
|
||
User,
|
||
UserKnowledge,
|
||
)
|
||
from schemas import ImportPayload, ImportResult
|
||
from services.knowledge_service import ensure_knowledge
|
||
|
||
|
||
router = APIRouter(prefix="/archive", tags=["archive"])
|
||
|
||
|
||
def _as_date(value) -> date:
|
||
if isinstance(value, date):
|
||
return value
|
||
return date.fromisoformat(str(value))
|
||
|
||
|
||
def _options_to_json(value) -> str:
|
||
if isinstance(value, list):
|
||
return json.dumps([str(option) for option in value], ensure_ascii=False)
|
||
return ""
|
||
|
||
|
||
def _parse_options(value) -> list[str]:
|
||
if not isinstance(value, list):
|
||
return []
|
||
return [str(option) for option in value]
|
||
|
||
|
||
def _parse_item_dict(value, index: int) -> dict:
|
||
if not isinstance(value, dict):
|
||
raise ValueError(f"第 {index} 题明细格式不正确")
|
||
stem = str(value.get("stem", "")).strip()
|
||
options = _parse_options(value.get("options"))
|
||
if not stem or len(options) != 4:
|
||
raise ValueError(f"第 {index} 题明细缺少题干或选项")
|
||
try:
|
||
correct_index = int(value.get("correct_index", -1))
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"第 {index} 题明细 correct_index 不是数字") from exc
|
||
selected = value.get("selected")
|
||
if selected is not None:
|
||
try:
|
||
selected = int(selected)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError(f"第 {index} 题明细 selected 不是数字") from exc
|
||
raw_names = value.get("knowledge_names") or []
|
||
if not isinstance(raw_names, list) or not raw_names:
|
||
raw_names = [value.get("knowledge_name") or "章节综合"]
|
||
return {
|
||
"question_index": index,
|
||
"question_id": value.get("question_id"),
|
||
"stem": stem,
|
||
"options": options,
|
||
"correct_index": correct_index,
|
||
"selected": selected,
|
||
"correct": bool(value.get("correct", correct_index == selected)),
|
||
"knowledge_name": str(value.get("knowledge_name", "")).strip() or "章节综合",
|
||
"knowledge_names": [str(name) for name in raw_names],
|
||
"explanation": str(value.get("explanation", "")).strip(),
|
||
}
|
||
|
||
|
||
def _parse_record(item) -> dict:
|
||
if isinstance(item, list):
|
||
if len(item) != 4:
|
||
raise ValueError("练习记录需要 4 个字段")
|
||
return {
|
||
"happened_on": _as_date(item[0]),
|
||
"mode": "imported",
|
||
"title": str(item[1]),
|
||
"total": int(item[2]),
|
||
"correct": int(item[3]),
|
||
"items": [],
|
||
}
|
||
if isinstance(item, dict):
|
||
raw_items = item.get("items") or []
|
||
parsed_items = []
|
||
for index, raw in enumerate(raw_items, start=1):
|
||
parsed_items.append(_parse_item_dict(raw, index))
|
||
return {
|
||
"happened_on": _as_date(item.get("date") or item.get("happened_on")),
|
||
"mode": str(item.get("mode", "imported")),
|
||
"title": str(item.get("title", "")),
|
||
"total": int(item.get("total", 0)),
|
||
"correct": int(item.get("correct", 0)),
|
||
"items": parsed_items,
|
||
}
|
||
raise ValueError("练习记录格式不正确")
|
||
|
||
|
||
def _parse_error(item) -> dict:
|
||
if isinstance(item, list):
|
||
if len(item) != 3:
|
||
raise ValueError("错题需要 3 个字段")
|
||
return {
|
||
"source_label": str(item[0]),
|
||
"knowledge_name": str(item[1]),
|
||
"knowledge_names": [str(item[1])],
|
||
"diagnosis": str(item[2]),
|
||
"stem": "",
|
||
"options": [],
|
||
"selected": None,
|
||
"correct_index": None,
|
||
"explanation": "",
|
||
}
|
||
if isinstance(item, dict):
|
||
raw_names = item.get("knowledge_names") or []
|
||
knowledge_name = str(item.get("knowledge_name", ""))
|
||
if not isinstance(raw_names, list) or not raw_names:
|
||
raw_names = [knowledge_name]
|
||
return {
|
||
"source_label": str(item.get("source_label", "")),
|
||
"knowledge_name": knowledge_name,
|
||
"knowledge_names": [str(name) for name in raw_names],
|
||
"diagnosis": str(item.get("diagnosis", "")),
|
||
"stem": str(item.get("stem", "")),
|
||
"options": _parse_options(item.get("options")),
|
||
"selected": item.get("selected"),
|
||
"correct_index": item.get("correct_index"),
|
||
"explanation": str(item.get("explanation", "")),
|
||
}
|
||
raise ValueError("错题格式不正确")
|
||
|
||
|
||
def _session_items(db: Session, session_id: int) -> list[dict]:
|
||
rows = (
|
||
db.query(AttemptItem)
|
||
.filter(AttemptItem.attempt_id == session_id)
|
||
.order_by(AttemptItem.question_index.asc(), AttemptItem.id.asc())
|
||
.all()
|
||
)
|
||
result = []
|
||
for row in rows:
|
||
try:
|
||
options = json.loads(row.options)
|
||
if not isinstance(options, list):
|
||
options = []
|
||
except (TypeError, ValueError):
|
||
options = []
|
||
result.append(
|
||
{
|
||
"question_index": row.question_index,
|
||
"question_id": row.question_id,
|
||
"stem": row.stem,
|
||
"options": [str(option) for option in options],
|
||
"correct_index": row.correct_index,
|
||
"selected": row.selected,
|
||
"correct": bool(row.correct),
|
||
"knowledge_name": row.knowledge_name,
|
||
"knowledge_names": (
|
||
json.loads(row.knowledge_names)
|
||
if row.knowledge_names
|
||
else [row.knowledge_name]
|
||
),
|
||
"explanation": row.explanation,
|
||
}
|
||
)
|
||
return result
|
||
|
||
|
||
@router.get("/export")
|
||
def export_archive(
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(require_learning_user),
|
||
):
|
||
knowledge = [
|
||
{"name": knowledge.name, "mastery": user_knowledge.mastery}
|
||
for user_knowledge, knowledge in (
|
||
db.query(UserKnowledge, Knowledge)
|
||
.join(Knowledge, Knowledge.id == UserKnowledge.knowledge_id)
|
||
.filter(UserKnowledge.user_id == user.id)
|
||
.order_by(UserKnowledge.position)
|
||
.all()
|
||
)
|
||
]
|
||
sessions = (
|
||
db.query(AttemptSession)
|
||
.filter(AttemptSession.user_id == user.id)
|
||
.order_by(AttemptSession.id)
|
||
.all()
|
||
)
|
||
records = [
|
||
{
|
||
"date": session.happened_on.isoformat(),
|
||
"mode": session.mode,
|
||
"title": session.title,
|
||
"total": session.total,
|
||
"correct": session.correct,
|
||
"items": _session_items(db, session.id),
|
||
}
|
||
for session in sessions
|
||
]
|
||
errors = []
|
||
for error in (
|
||
db.query(ErrorEntry)
|
||
.filter(ErrorEntry.user_id == user.id)
|
||
.order_by(ErrorEntry.id)
|
||
.all()
|
||
):
|
||
item = {
|
||
"source_label": error.source_label,
|
||
"knowledge_name": error.knowledge_name,
|
||
"knowledge_names": (
|
||
json.loads(error.knowledge_names)
|
||
if error.knowledge_names
|
||
else [error.knowledge_name]
|
||
),
|
||
"diagnosis": error.diagnosis,
|
||
"stem": error.stem,
|
||
"selected": error.selected,
|
||
"correct_index": error.correct_index,
|
||
"explanation": error.explanation,
|
||
}
|
||
if error.options:
|
||
try:
|
||
parsed = json.loads(error.options)
|
||
item["options"] = parsed if isinstance(parsed, list) else []
|
||
except (TypeError, ValueError):
|
||
item["options"] = []
|
||
errors.append(item)
|
||
daily = [
|
||
d.done_on.isoformat()
|
||
for d in db.query(DailyCompletion)
|
||
.filter(DailyCompletion.user_id == user.id)
|
||
.order_by(DailyCompletion.done_on)
|
||
.all()
|
||
]
|
||
payload = {
|
||
"version": 2,
|
||
"exported_at": datetime.now().isoformat(timespec="seconds"),
|
||
"knowledge": knowledge,
|
||
"records": records,
|
||
"errors": errors,
|
||
"daily_completions": daily,
|
||
}
|
||
return Response(
|
||
content=json.dumps(payload, ensure_ascii=False, indent=2),
|
||
media_type="application/json",
|
||
headers={
|
||
"Content-Disposition": 'attachment; filename="math-learning-archive.json"'
|
||
},
|
||
)
|
||
|
||
|
||
@router.post("/import", response_model=ImportResult)
|
||
def import_archive(
|
||
payload: ImportPayload,
|
||
db: Session = Depends(get_db),
|
||
user: User = Depends(require_learning_user),
|
||
):
|
||
if not payload.knowledge:
|
||
raise HTTPException(status_code=400, detail="档案中缺少 knowledge 字段")
|
||
|
||
knowledge_items = []
|
||
for item in payload.knowledge:
|
||
name = str(item.get("name", "")).strip()
|
||
mastery = item.get("mastery", item.get("v"))
|
||
if not name or mastery is None:
|
||
raise HTTPException(status_code=400, detail="knowledge 项缺少 name 或 mastery")
|
||
knowledge_items.append({"name": name, "mastery": max(0, min(100, int(mastery)))})
|
||
|
||
parsed_records: list[dict] = []
|
||
for item in payload.records:
|
||
try:
|
||
parsed_records.append(_parse_record(item))
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(status_code=400, detail=f"记录格式错误:{exc}") from exc
|
||
|
||
parsed_errors: list[dict] = []
|
||
for item in payload.errors:
|
||
try:
|
||
parsed_errors.append(_parse_error(item))
|
||
except (ValueError, TypeError) as exc:
|
||
raise HTTPException(status_code=400, detail=f"错题格式错误:{exc}") from exc
|
||
|
||
try:
|
||
done_dates = [_as_date(value) for value in payload.daily_completions]
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail="日期格式应为 YYYY-MM-DD") from exc
|
||
|
||
# 恢复式导入:档案内容作为当前学习状态替换进来
|
||
db.query(AttemptItem).filter(AttemptItem.user_id == user.id).delete()
|
||
db.query(DailyCompletion).filter(DailyCompletion.user_id == user.id).delete()
|
||
db.query(ErrorEntry).filter(ErrorEntry.user_id == user.id).delete()
|
||
db.query(AttemptSession).filter(AttemptSession.user_id == user.id).delete()
|
||
db.query(UserKnowledge).filter(UserKnowledge.user_id == user.id).delete()
|
||
|
||
for index, item in enumerate(knowledge_items):
|
||
knowledge = ensure_knowledge(db, item["name"])
|
||
db.add(
|
||
UserKnowledge(
|
||
user_id=user.id,
|
||
knowledge_id=knowledge.id,
|
||
mastery=item["mastery"],
|
||
position=index,
|
||
)
|
||
)
|
||
for done_on in done_dates:
|
||
db.add(DailyCompletion(user_id=user.id, done_on=done_on))
|
||
|
||
item_count = 0
|
||
for error in parsed_errors:
|
||
db.add(
|
||
ErrorEntry(
|
||
user_id=user.id,
|
||
source_label=error["source_label"],
|
||
knowledge_name=error["knowledge_name"],
|
||
knowledge_names=json.dumps(
|
||
error["knowledge_names"], ensure_ascii=False
|
||
),
|
||
diagnosis=error["diagnosis"],
|
||
stem=error["stem"],
|
||
options=_options_to_json(error["options"]),
|
||
selected=error["selected"],
|
||
correct_index=error["correct_index"],
|
||
explanation=error["explanation"],
|
||
)
|
||
)
|
||
|
||
for record in parsed_records:
|
||
session = AttemptSession(
|
||
user_id=user.id,
|
||
mode=record["mode"],
|
||
title=record["title"],
|
||
total=record["total"],
|
||
correct=record["correct"],
|
||
happened_on=record["happened_on"],
|
||
)
|
||
db.add(session)
|
||
db.flush()
|
||
for item in record["items"]:
|
||
db.add(
|
||
AttemptItem(
|
||
attempt_id=session.id,
|
||
user_id=user.id,
|
||
question_index=item["question_index"],
|
||
# 题库题目可能已被删除,导入只保留快照不重建外键
|
||
question_id=None,
|
||
stem=item["stem"],
|
||
options=_options_to_json(item["options"]),
|
||
correct_index=item["correct_index"],
|
||
selected=item.get("selected"),
|
||
correct=bool(item.get("correct")),
|
||
knowledge_name=item["knowledge_name"],
|
||
knowledge_names=json.dumps(
|
||
item["knowledge_names"], ensure_ascii=False
|
||
),
|
||
explanation=item["explanation"],
|
||
)
|
||
)
|
||
item_count += 1
|
||
db.commit()
|
||
return ImportResult(
|
||
imported=True,
|
||
knowledge=len(knowledge_items),
|
||
records=len(parsed_records),
|
||
errors=len(parsed_errors),
|
||
items=item_count,
|
||
message="学习档案导入成功。",
|
||
)
|