first commit
This commit is contained in:
283
app/main.py
Normal file
283
app/main.py
Normal file
@@ -0,0 +1,283 @@
|
||||
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.
|
||||
Reference in New Issue
Block a user