Files
hangbiao/app/main.py
2026-09-22 10:48:18 +08:00

284 lines
9.9 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 .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.