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 .indicators import analyze, class_comment, default_targets, 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"]}) base = { "exams": parsed["exams"], "include_art": False, "include_leave": False, "include_mingyuan": True, "include_other": True, "horizon": 1, "targets": default_targets(groups), } if stored: for k in ("exams", "include_art", "include_leave", "include_mingyuan", "include_other", "horizon", "targets"): 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() exams = form.getlist("exams") if not exams: exams = parsed["exams"] groups = sorted({infer_track_group(s["track"]) for s in parsed["students"]}) targets = {} for g in groups: names = form.getlist(f"t_name_{g}") ks = form.getlist(f"t_k_{g}") ids = form.getlist(f"t_id_{g}") rows = [] for i, name in enumerate(names): k_raw = ks[i] if i < len(ks) else "0" try: k = int(float(k_raw)) except ValueError: k = 0 tid = ids[i] if i < len(ids) and ids[i] else f"t{i+1}" if name.strip() and k > 0: rows.append({"id": tid, "name": name.strip(), "k": k}) targets[g] = rows or default_targets([g])[g] settings = { "exams": list(exams), "include_art": form.get("include_art") == "on", "include_leave": form.get("include_leave") == "on", "include_mingyuan": form.get("include_mingyuan") == "on", "include_other": form.get("include_other") == "on", "horizon": int(form.get("horizon") or 1), "targets": targets, } 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("probs") or {}).get(target) or 0)) return {"exams": result["exams"], "students": rows[:80], "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"]) 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.