302 lines
11 KiB
Python
302 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import uuid
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from .config import (
|
|
ADMIN_PASSWORD,
|
|
ADMIN_USER,
|
|
DATA_DIR,
|
|
MAX_UPLOAD_MB,
|
|
SAMPLE_XLSX,
|
|
SECRET_KEY,
|
|
UPLOAD_DIR,
|
|
)
|
|
from .db import (
|
|
create_analysis,
|
|
get_analysis,
|
|
init_db,
|
|
list_analyses,
|
|
load_json,
|
|
save_json,
|
|
update_analysis,
|
|
)
|
|
from .excel_parse import parse_workbook, summarize_parse
|
|
from .export_xlsx import build_export
|
|
from .layer_method import analyze, class_comment, default_line_settings, infer_track_group, select_students
|
|
|
|
app = FastAPI(title="指标测算 · 相邻层次法")
|
|
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY, max_age=60 * 60 * 12)
|
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
|
|
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
|
templates.env.globals["class_comment"] = class_comment
|
|
|
|
|
|
def render(request: Request, name: str, **ctx):
|
|
ctx["request"] = request
|
|
ctx.setdefault("user", current_user(request))
|
|
return templates.TemplateResponse(request, name, ctx)
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
init_db()
|
|
|
|
|
|
def current_user(request: Request) -> str | None:
|
|
return request.session.get("user")
|
|
|
|
|
|
def require_login(request: Request):
|
|
if not current_user(request):
|
|
return RedirectResponse("/login", status_code=303)
|
|
return None
|
|
|
|
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
def login_page(request: Request):
|
|
if current_user(request):
|
|
return RedirectResponse("/", status_code=303)
|
|
return render(request, "login.html", error=None)
|
|
|
|
|
|
@app.post("/login", response_class=HTMLResponse)
|
|
def login_submit(request: Request, username: str = Form(...), password: str = Form(...)):
|
|
if username.strip() == ADMIN_USER and password == ADMIN_PASSWORD:
|
|
request.session["user"] = username.strip()
|
|
return RedirectResponse("/", status_code=303)
|
|
return render(request, "login.html", error="账号或密码不正确")
|
|
|
|
|
|
@app.post("/logout")
|
|
def logout(request: Request):
|
|
request.session.clear()
|
|
return RedirectResponse("/login", status_code=303)
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def home(request: Request):
|
|
if (redir := require_login(request)):
|
|
return redir
|
|
items = list_analyses()
|
|
return render(request, "home.html", items=items, has_sample=SAMPLE_XLSX.exists())
|
|
|
|
|
|
def _store_parsed(title: str, filename: str, source: Path) -> int:
|
|
parsed = parse_workbook(source)
|
|
summary = summarize_parse(parsed)
|
|
token = uuid.uuid4().hex[:10]
|
|
parsed_path = str(DATA_DIR / f"parsed_{token}.json")
|
|
# keep a lighter parsed blob
|
|
save_json(parsed_path, parsed)
|
|
aid = create_analysis(title, filename, parsed_path)
|
|
update_analysis(aid, settings_json=json.dumps({"summary": summary}, ensure_ascii=False))
|
|
return aid
|
|
|
|
|
|
@app.post("/upload")
|
|
async def upload(request: Request, file: UploadFile = File(...)):
|
|
if (redir := require_login(request)):
|
|
return redir
|
|
raw = await file.read()
|
|
if len(raw) > MAX_UPLOAD_MB * 1024 * 1024:
|
|
return HTMLResponse("文件过大,请压缩到 15MB 以内。", status_code=400)
|
|
suffix = Path(file.filename or "scores.xlsx").suffix.lower()
|
|
if suffix not in (".xlsx", ".xlsm"):
|
|
return HTMLResponse("请上传 .xlsx 成绩表。", status_code=400)
|
|
dest = UPLOAD_DIR / f"{uuid.uuid4().hex}{suffix}"
|
|
dest.write_bytes(raw)
|
|
aid = _store_parsed(Path(file.filename).stem, file.filename or dest.name, dest)
|
|
return RedirectResponse(f"/analysis/{aid}?step=1", status_code=303)
|
|
|
|
|
|
@app.post("/demo")
|
|
def demo(request: Request):
|
|
if (redir := require_login(request)):
|
|
return redir
|
|
if not SAMPLE_XLSX.exists():
|
|
return HTMLResponse("未找到示例成绩表。", status_code=404)
|
|
dest = UPLOAD_DIR / f"{uuid.uuid4().hex}.xlsx"
|
|
shutil.copy2(SAMPLE_XLSX, dest)
|
|
aid = _store_parsed("2024级示例数据", SAMPLE_XLSX.name, dest)
|
|
return RedirectResponse(f"/analysis/{aid}?step=1", status_code=303)
|
|
|
|
|
|
def _settings_from(parsed: dict, stored: dict | None) -> dict:
|
|
summary = summarize_parse(parsed)
|
|
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
|
line_settings = default_line_settings(groups)
|
|
base = {
|
|
"exams": parsed["exams"],
|
|
"exclude_moves": False,
|
|
"include_mingyuan": True,
|
|
"include_other": True,
|
|
"line_settings": line_settings,
|
|
}
|
|
if stored:
|
|
# 考试列必须始终来自本次上传的工作簿,避免旧分析设置把不存在的列带进来。
|
|
valid_exam_set = set(parsed["exams"])
|
|
stored_exams = [e for e in (stored.get("exams") or []) if e in valid_exam_set]
|
|
base["exams"] = stored_exams or parsed["exams"]
|
|
for k in ("exclude_moves", "include_mingyuan", "include_other", "line_settings"):
|
|
if k in stored:
|
|
base[k] = stored[k]
|
|
base["summary"] = summary
|
|
base["groups"] = groups
|
|
return base
|
|
|
|
|
|
|
|
@app.get("/analysis/{aid}", response_class=HTMLResponse)
|
|
def analysis_page(request: Request, aid: int, step: int = 1):
|
|
if (redir := require_login(request)):
|
|
return redir
|
|
item = get_analysis(aid)
|
|
if not item:
|
|
return HTMLResponse("分析不存在", status_code=404)
|
|
parsed = load_json(item["parsed_path"])
|
|
stored_settings = json.loads(item["settings_json"] or "{}")
|
|
settings = _settings_from(parsed, stored_settings)
|
|
result = load_json(item["result_path"]) if item.get("result_path") else None
|
|
preview_n = None
|
|
if step == 2:
|
|
preview_n = len(select_students(parsed, settings))
|
|
return render(
|
|
request,
|
|
"analysis.html",
|
|
item=item,
|
|
step=step,
|
|
settings=settings,
|
|
summary=settings["summary"],
|
|
result=result,
|
|
preview_n=preview_n,
|
|
)
|
|
|
|
|
|
@app.post("/analysis/{aid}/settings")
|
|
async def save_settings(request: Request, aid: int):
|
|
if (redir := require_login(request)):
|
|
return redir
|
|
item = get_analysis(aid)
|
|
if not item:
|
|
return HTMLResponse("分析不存在", status_code=404)
|
|
parsed = load_json(item["parsed_path"])
|
|
form = await request.form()
|
|
valid_exam_set = set(parsed["exams"])
|
|
exams = [e for e in form.getlist("exams") if e in valid_exam_set]
|
|
if not exams:
|
|
exams = parsed["exams"]
|
|
if len(exams) < 2:
|
|
return HTMLResponse("至少需要勾选两次考试才能计算相邻转移概率。", status_code=400)
|
|
groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]})
|
|
defaults = default_line_settings(groups)
|
|
line_settings = {}
|
|
for g in groups:
|
|
try:
|
|
top = int(float(form.get(f"top_k_{g}", defaults[g]["top"])))
|
|
except ValueError:
|
|
top = defaults[g]["top"]
|
|
try:
|
|
control = int(float(form.get(f"control_k_{g}", defaults[g]["control"])))
|
|
except ValueError:
|
|
control = defaults[g]["control"]
|
|
try:
|
|
layer_size = int(float(form.get(f"layer_size_{g}", defaults[g]["layer_size"])))
|
|
except ValueError:
|
|
layer_size = defaults[g]["layer_size"]
|
|
line_settings[g] = {
|
|
"top": max(1, top),
|
|
"control": max(1, max(top, control)),
|
|
"layer_size": max(1, layer_size),
|
|
}
|
|
settings = {
|
|
"exams": list(exams),
|
|
"exclude_moves": form.get("exclude_moves") == "on",
|
|
"include_mingyuan": form.get("include_mingyuan") == "on",
|
|
"include_other": form.get("include_other") == "on",
|
|
"line_settings": line_settings,
|
|
}
|
|
update_analysis(aid, settings_json=json.dumps(settings, ensure_ascii=False), step=2)
|
|
action = form.get("action") or "next"
|
|
if action == "compute":
|
|
result = analyze(parsed, settings)
|
|
token = uuid.uuid4().hex[:10]
|
|
result_path = str(DATA_DIR / f"result_{token}.json")
|
|
save_json(result_path, result)
|
|
update_analysis(aid, result_path=result_path, step=3)
|
|
return RedirectResponse(f"/analysis/{aid}?step=3", status_code=303)
|
|
return RedirectResponse(f"/analysis/{aid}?step=2", status_code=303)
|
|
|
|
|
|
|
|
@app.get("/analysis/{aid}/api/student/{sid}")
|
|
def student_api(request: Request, aid: int, sid: str):
|
|
if not current_user(request):
|
|
return Response("unauthorized", status_code=401)
|
|
item = get_analysis(aid)
|
|
if not item or not item.get("result_path"):
|
|
return Response("not found", status_code=404)
|
|
result = load_json(item["result_path"])
|
|
for s in result["students"]:
|
|
if str(s["sid"]) == str(sid):
|
|
return s
|
|
return Response("not found", status_code=404)
|
|
|
|
|
|
@app.get("/analysis/{aid}/api/class")
|
|
def class_api(request: Request, aid: int, group: str, target: str, class_name: str):
|
|
if not current_user(request):
|
|
return Response("unauthorized", status_code=401)
|
|
item = get_analysis(aid)
|
|
if not item or not item.get("result_path"):
|
|
return Response("not found", status_code=404)
|
|
result = load_json(item["result_path"])
|
|
rows = [
|
|
s
|
|
for s in result["students"]
|
|
if s["group"] == group and s["class_name"] == class_name
|
|
]
|
|
rows.sort(key=lambda s: (s.get("ranks") or {}).get(result["exams"][-1], 10**9))
|
|
return {
|
|
"exams": result["exams"],
|
|
"layer_size": result.get("layer_size"),
|
|
"last_exam": result["exams"][-1],
|
|
"students": rows,
|
|
"n": len(rows),
|
|
}
|
|
|
|
|
|
@app.get("/analysis/{aid}/export")
|
|
def export_analysis(request: Request, aid: int):
|
|
if (redir := require_login(request)):
|
|
return redir
|
|
item = get_analysis(aid)
|
|
if not item or not item.get("result_path"):
|
|
return HTMLResponse("请先完成测算", status_code=400)
|
|
result = load_json(item["result_path"])
|
|
if result.get("algorithm") != "adjacent-layer-markov-v2":
|
|
return HTMLResponse("结果为旧算法,请回到第二步重新测算后再导出。", status_code=400)
|
|
data = build_export(result)
|
|
ascii_name = f"hangbiao-{aid}.xlsx"
|
|
star = quote(f"{item['title']}-下达指标.xlsx")
|
|
return Response(
|
|
content=data,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": f"attachment; filename={ascii_name}; filename*=UTF-8''{star}"},
|
|
)
|
|
|
|
|
|
# Jinja 3 / FastAPI 0.115+ TemplateResponse signature is (request, name, context)
|
|
# Keep compatibility if older: handled by FastAPI version we pin.
|