710 lines
24 KiB
Python
710 lines
24 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from flask import Flask, abort, jsonify, redirect, render_template, request, url_for
|
|
|
|
APP_DIR = Path(__file__).resolve().parent
|
|
ROOT_DIR = APP_DIR.parent
|
|
if str(ROOT_DIR) not in sys.path:
|
|
sys.path.insert(0, str(ROOT_DIR))
|
|
|
|
from register_game_playwright import normalize_team_name
|
|
|
|
JOBS_DIR = ROOT_DIR / "jobs"
|
|
LOGS_DIR = ROOT_DIR / "logs"
|
|
OUTPUT_DIR = ROOT_DIR / "output"
|
|
TEAM_OPTIONS = [
|
|
{"value": "HT", "label": "KIA"},
|
|
{"value": "KT", "label": "KT"},
|
|
{"value": "LG", "label": "LG"},
|
|
{"value": "NC", "label": "NC"},
|
|
{"value": "OB", "label": "두산"},
|
|
{"value": "LT", "label": "롯데"},
|
|
{"value": "SK", "label": "SSG"},
|
|
{"value": "SS", "label": "삼성"},
|
|
{"value": "WO", "label": "키움"},
|
|
{"value": "HH", "label": "한화"},
|
|
]
|
|
GAME_TYPE_OPTIONS = [
|
|
{"value": "regular", "label": "정규경기"},
|
|
{"value": "wildcard", "label": "와일드카드"},
|
|
{"value": "semi_playoff", "label": "준PO"},
|
|
{"value": "playoff", "label": "PO"},
|
|
{"value": "korean_series", "label": "한국시리즈"},
|
|
]
|
|
GAME_TYPE_CODE_MAP = {
|
|
"regular": None,
|
|
"wildcard": "4444",
|
|
"semi_playoff": "3333",
|
|
"playoff": "5555",
|
|
"korean_series": "7777",
|
|
}
|
|
|
|
JOBS_DIR.mkdir(exist_ok=True)
|
|
LOGS_DIR.mkdir(exist_ok=True)
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
def safe_name(value: str) -> str:
|
|
return "".join(char for char in value if char.isalnum() or char in {"-", "_"})
|
|
|
|
|
|
def job_path(job_id: str) -> Path:
|
|
return JOBS_DIR / f"{job_id}.json"
|
|
|
|
|
|
def log_path(job_id: str) -> Path:
|
|
return LOGS_DIR / f"{job_id}.log"
|
|
|
|
|
|
def load_job(job_id: str) -> dict[str, Any]:
|
|
path = job_path(job_id)
|
|
if not path.exists():
|
|
raise FileNotFoundError(job_id)
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def save_job(data: dict[str, Any]) -> None:
|
|
job_id = data["job_id"]
|
|
job_path(job_id).write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def list_jobs(limit: int = 30) -> list[dict[str, Any]]:
|
|
jobs: list[dict[str, Any]] = []
|
|
for path in sorted(JOBS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
try:
|
|
jobs.append(json.loads(path.read_text(encoding="utf-8")))
|
|
except Exception:
|
|
continue
|
|
if len(jobs) >= limit:
|
|
break
|
|
return jobs
|
|
|
|
|
|
def tail_log_preview(job_id: str) -> str:
|
|
path = log_path(job_id)
|
|
if not path.exists():
|
|
return "로그 없음"
|
|
try:
|
|
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
except Exception:
|
|
return "로그 읽기 실패"
|
|
if not lines:
|
|
return "로그 없음"
|
|
for line in reversed(lines):
|
|
if line.startswith("[") and ("START " in line or "END " in line):
|
|
continue
|
|
return line[:96]
|
|
return lines[-1][:96]
|
|
|
|
|
|
def enrich_job(job: dict[str, Any]) -> dict[str, Any]:
|
|
enriched = dict(job)
|
|
enriched["log_preview"] = tail_log_preview(job["job_id"])
|
|
return enriched
|
|
|
|
|
|
def get_active_job() -> dict[str, Any] | None:
|
|
for job in list_jobs(limit=200):
|
|
if job.get("status") in {"queued", "running"}:
|
|
return enrich_job(job)
|
|
return None
|
|
|
|
|
|
def has_active_job() -> bool:
|
|
return get_active_job() is not None
|
|
|
|
|
|
def list_recent_reports(limit: int = 12) -> list[dict[str, Any]]:
|
|
reports: list[dict[str, Any]] = []
|
|
for path in sorted(OUTPUT_DIR.glob("*_report.json"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
continue
|
|
game_info = payload.get("game_info") or {}
|
|
reports.append(
|
|
{
|
|
"game_id": payload.get("game_id") or path.name.replace("_report.json", ""),
|
|
"date": game_info.get("date") or "-",
|
|
"home_team": game_info.get("home_team") or "-",
|
|
"away_team": game_info.get("away_team") or "-",
|
|
"start_time": game_info.get("start_time") or "-",
|
|
"path": str(path),
|
|
}
|
|
)
|
|
if len(reports) >= limit:
|
|
break
|
|
return reports
|
|
|
|
|
|
def resolve_report_path(game_id: str, report_path: str | None) -> Path:
|
|
if report_path:
|
|
return Path(report_path)
|
|
return OUTPUT_DIR / f"{game_id}_report.json"
|
|
|
|
|
|
def requires_report(job_type: str) -> bool:
|
|
return job_type == "register_basic"
|
|
|
|
|
|
def validate_job_request(job_type: str, game_id: str, manager_mode: str, manager_game_no: str, report_path: str, inning_no: str) -> str | None:
|
|
if not game_id:
|
|
return "경기 식별 정보가 올바르지 않습니다. game_id 또는 날짜/팀 조합을 확인하세요."
|
|
if manager_mode == "manual" and job_type in {"lineup", "record", "finish", "post_update"} and not manager_game_no:
|
|
return "관리자 게임번호 직접 입력을 선택했다면 게임번호를 입력하세요."
|
|
def get_inn_val(s: str) -> float | None:
|
|
if not s: return None
|
|
m = re.match(r"^(\d+)([TB]?)$", s.upper().strip())
|
|
if not m: return None
|
|
num = int(m.group(1))
|
|
half = 0.5 if m.group(2) == "B" else 0.0
|
|
return num + half
|
|
|
|
if job_type == "record":
|
|
if not inning_no:
|
|
return "경기기록 입력은 이닝을 선택해야 합니다."
|
|
if inning_no != "all":
|
|
if "-" in inning_no:
|
|
start_str, end_str = inning_no.split("-", 1)
|
|
s_val = get_inn_val(start_str)
|
|
e_val = get_inn_val(end_str)
|
|
if s_val is None or e_val is None:
|
|
return f"이닝 범위 형식이 올바르지 않습니다 (예: 3B-9B). 입력값: {inning_no}"
|
|
if not (1.0 <= s_val <= e_val <= 15.5):
|
|
return "이닝 범위가 올바르지 않습니다 (1회 초 ~ 15회 말)."
|
|
else:
|
|
val = get_inn_val(inning_no)
|
|
if val is None:
|
|
return f"이닝 형식이 올바르지 않습니다 (예: 5T). 입력값: {inning_no}"
|
|
if not (1.0 <= val <= 15.5):
|
|
return "이닝은 1회부터 15회까지만 선택할 수 있습니다."
|
|
if requires_report(job_type):
|
|
resolved = resolve_report_path(game_id, report_path)
|
|
# 자동화 작업들은 첫 단계에서 리포트를 생성하므로 사전 파일 체크를 하지 않음
|
|
# "수동 모드"나 특수한 경우가 아니면 거의 모든 자동화 버튼을 허용
|
|
automated_jobs = {"lineup", "record", "register_basic", "post_update", "finish", "compare", "video_review"}
|
|
if job_type not in automated_jobs and not resolved.exists():
|
|
return f"리포트 파일이 없습니다: {resolved}"
|
|
return None
|
|
|
|
|
|
def normalize_team_key(value: str) -> str:
|
|
raw = (value or "").strip()
|
|
if not raw:
|
|
return ""
|
|
return raw.casefold()
|
|
|
|
|
|
def team_label_from_code(team_code: str) -> str:
|
|
for option in TEAM_OPTIONS:
|
|
if option["value"] == team_code:
|
|
return option["label"]
|
|
return team_code
|
|
|
|
|
|
def team_alias_keys(value: str) -> set[str]:
|
|
raw = (value or "").strip()
|
|
if not raw:
|
|
return set()
|
|
normalized = normalize_team_name(raw)
|
|
return {raw.casefold(), normalized.casefold()}
|
|
|
|
|
|
def compose_game_id_from_form(form) -> str:
|
|
def clean(v: str) -> str:
|
|
return "".join((v or "").split())
|
|
|
|
game_type = clean(form.get("game_type") or "regular")
|
|
game_date = clean(form.get("game_date"))
|
|
away_code = clean(form.get("away_team_code")).upper()
|
|
home_code = clean(form.get("home_team_code")).upper()
|
|
dh_raw = clean(form.get("doubleheader_no") or "0")
|
|
|
|
if not game_date or not away_code or not home_code:
|
|
return ""
|
|
pure_date = game_date.replace("-", "")
|
|
year = pure_date[:4]
|
|
month = pure_date[4:6]
|
|
day = pure_date[6:8]
|
|
|
|
type_code = GAME_TYPE_CODE_MAP.get(game_type)
|
|
if not type_code or game_type == "regular":
|
|
type_code = year
|
|
|
|
dh = "0"
|
|
if dh_raw.isdigit():
|
|
dh = str(max(0, min(9, int(dh_raw))))
|
|
|
|
# 네이버 표준 ID: [연도/타입][월][일][원정][홈][더블헤더][연도]
|
|
# 예: 2026 04 14 LT LG 0 2026
|
|
return f"{type_code}{month}{day}{away_code}{home_code}{dh}{year}"
|
|
|
|
|
|
def resolve_game_id_from_form(form) -> str:
|
|
mode = (form.get("game_id_mode") or "direct").strip()
|
|
raw_id = ""
|
|
if mode == "direct":
|
|
raw_id = (form.get("game_id") or "").strip()
|
|
else:
|
|
raw_id = compose_game_id_from_form(form)
|
|
|
|
# 영문/숫자만 남기고 한글 및 특수문자 제거
|
|
return "".join(re.findall(r"[A-Za-z0-9]", raw_id))
|
|
|
|
|
|
def build_generated_report_paths(game_id: str, job_type: str, inning_no: str | None = None) -> tuple[Path, Path]:
|
|
if job_type == "lineup":
|
|
stem = f"{game_id}_lineup_report"
|
|
elif job_type == "record" and inning_no and inning_no != "all":
|
|
# 범위를 파일명에 표시 (예: 5-9 -> inning_5-9)
|
|
safe_inning = safe_name(inning_no)
|
|
stem = f"{game_id}_inning_{safe_inning}_report"
|
|
elif job_type == "record":
|
|
stem = f"{game_id}_full_record_report"
|
|
elif job_type == "finish":
|
|
stem = f"{game_id}_finish_report"
|
|
elif job_type == "post_update":
|
|
stem = f"{game_id}_post_report"
|
|
elif job_type == "compare":
|
|
stem = f"{game_id}_compare_report"
|
|
elif job_type == "video_review":
|
|
stem = f"{game_id}_review_report"
|
|
else:
|
|
stem = f"{game_id}_report"
|
|
return OUTPUT_DIR / f"{stem}.json", OUTPUT_DIR / f"{stem}.txt"
|
|
|
|
|
|
def build_command(job_type: str, game_id: str, manager_game_no: str | None, report_path: str | None, inning_no: str | None = None) -> list[str]:
|
|
python_exe = sys.executable
|
|
cmd = [python_exe]
|
|
|
|
if job_type == "register_basic":
|
|
cmd.extend([str(ROOT_DIR / "register_game_basic_playwright.py"), "--game-id", game_id, "--close"])
|
|
elif job_type == "lineup":
|
|
cmd.extend([str(ROOT_DIR / "lineup_only_playwright.py"), "--game-id", game_id, "--close"])
|
|
if manager_game_no:
|
|
cmd.extend(["--manager-game-no", manager_game_no])
|
|
elif job_type == "record":
|
|
cmd.extend([str(ROOT_DIR / "record_game_playwright.py"), "--game-id", game_id, "--close"])
|
|
if manager_game_no:
|
|
cmd.extend(["--manager-game-no", manager_game_no])
|
|
elif job_type == "finish":
|
|
cmd.extend([str(ROOT_DIR / "finish_game_playwright.py"), "--game-id", game_id, "--close"])
|
|
if manager_game_no:
|
|
cmd.extend(["--manager-game-no", manager_game_no])
|
|
elif job_type == "post_update":
|
|
cmd.extend([str(ROOT_DIR / "update_game_post_playwright.py"), "--game-id", game_id, "--close"])
|
|
if manager_game_no:
|
|
cmd.extend(["--manager-game-no", manager_game_no])
|
|
elif job_type == "compare":
|
|
cmd.extend([str(ROOT_DIR / "compare_history_with_report.py"), "--game-id", game_id])
|
|
elif job_type == "video_review":
|
|
cmd.extend([str(ROOT_DIR / "video_review_playwright.py"), "--game-id", game_id, "--review-only", "--close"])
|
|
else:
|
|
raise ValueError(f"지원하지 않는 작업 타입입니다: {job_type}")
|
|
|
|
if report_path:
|
|
cmd.extend(["--report-path", report_path])
|
|
return cmd
|
|
|
|
|
|
def build_job_steps(job: dict[str, Any]) -> list[list[str]]:
|
|
job_type = job["type"]
|
|
game_id = job["game_id"]
|
|
manager_game_no = job.get("manager_game_no") or None
|
|
report_path = job.get("report_path") or None
|
|
inning_no = job.get("inning_no") or None
|
|
python_exe = sys.executable
|
|
|
|
if job_type == "video_review":
|
|
return [
|
|
[
|
|
python_exe,
|
|
str(ROOT_DIR / "game_report.py"),
|
|
"--game-id",
|
|
game_id,
|
|
"--output-json",
|
|
report_path,
|
|
],
|
|
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
|
|
]
|
|
|
|
if job_type == "register_basic":
|
|
return [
|
|
[
|
|
python_exe,
|
|
str(ROOT_DIR / "game_report.py"),
|
|
"--game-id",
|
|
game_id,
|
|
"--output-json",
|
|
report_path,
|
|
],
|
|
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
|
|
]
|
|
|
|
if job_type == "lineup":
|
|
return [
|
|
[
|
|
python_exe,
|
|
str(ROOT_DIR / "game_report.py"),
|
|
"--game-id",
|
|
game_id,
|
|
"--lineup-only",
|
|
"--output-json",
|
|
report_path,
|
|
],
|
|
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
|
|
]
|
|
|
|
if job_type == "record":
|
|
report_command = [
|
|
python_exe,
|
|
str(ROOT_DIR / "game_report.py"),
|
|
"--game-id",
|
|
game_id,
|
|
"--output-json",
|
|
report_path,
|
|
]
|
|
if inning_no and inning_no != "all":
|
|
if "-" in inning_no:
|
|
try:
|
|
# 1T-9B 같은 형식 처리
|
|
start_val, end_val = inning_no.split("-", 1)
|
|
report_command.extend(["--start-inning", start_val, "--end-inning", end_val])
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
report_command.extend(["--inning", str(inning_no)])
|
|
return [
|
|
report_command,
|
|
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
|
|
]
|
|
|
|
if job_type in {"finish", "post_update"}:
|
|
return [
|
|
[
|
|
python_exe,
|
|
str(ROOT_DIR / "game_report.py"),
|
|
"--game-id",
|
|
game_id,
|
|
"--output-json",
|
|
report_path,
|
|
],
|
|
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
|
|
]
|
|
|
|
if job_type == "compare":
|
|
return [
|
|
[
|
|
python_exe,
|
|
str(ROOT_DIR / "game_report.py"),
|
|
"--game-id",
|
|
game_id,
|
|
"--output-json",
|
|
report_path,
|
|
"--output-txt",
|
|
str(Path(report_path).with_suffix(".txt")),
|
|
],
|
|
build_command(job_type, game_id, manager_game_no, report_path, inning_no),
|
|
]
|
|
|
|
return [build_command(job_type, game_id, manager_game_no, report_path, inning_no)]
|
|
|
|
|
|
def run_job(job_id: str) -> None:
|
|
job = load_job(job_id)
|
|
job["status"] = "running"
|
|
job["started_at"] = now_iso()
|
|
save_job(job)
|
|
|
|
env = os.environ.copy()
|
|
env["PYTHONIOENCODING"] = "utf-8"
|
|
env["JOB_ID"] = job_id
|
|
|
|
log_file = log_path(job_id)
|
|
with log_file.open("a", encoding="utf-8") as log:
|
|
steps = build_job_steps(job)
|
|
log.write(f"[{now_iso()}] START steps={len(steps)}\n")
|
|
log.flush()
|
|
try:
|
|
return_code = 0
|
|
for step_index, command in enumerate(steps, start=1):
|
|
log.write(f"[{now_iso()}] STEP {step_index}/{len(steps)} {' '.join(command)}\n")
|
|
log.flush()
|
|
process = subprocess.Popen(
|
|
command,
|
|
cwd=str(ROOT_DIR),
|
|
stdout=log,
|
|
stderr=log,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return_code = process.wait()
|
|
if return_code != 0:
|
|
break
|
|
job = load_job(job_id)
|
|
job["finished_at"] = now_iso()
|
|
job["return_code"] = return_code
|
|
if return_code == 0:
|
|
job["status"] = "success"
|
|
job["error"] = None
|
|
else:
|
|
job["status"] = "failed"
|
|
job["error"] = f"프로세스 종료 코드: {return_code}"
|
|
save_job(job)
|
|
log.write(f"[{now_iso()}] END return_code={return_code}\n")
|
|
except Exception as exc:
|
|
job = load_job(job_id)
|
|
job["status"] = "failed"
|
|
job["finished_at"] = now_iso()
|
|
job["error"] = str(exc)
|
|
save_job(job)
|
|
log.write(f"[{now_iso()}] EXCEPTION {exc}\n")
|
|
log.write(traceback.format_exc())
|
|
|
|
|
|
def create_job(job_type: str, game_id: str, manager_game_no: str | None, report_path: str | None, inning_no: str | None = None) -> dict[str, Any]:
|
|
normalized_game_id = safe_name(game_id) or "unknown"
|
|
job_id = f"{normalized_game_id}-{job_type}-{uuid.uuid4().hex[:8]}"
|
|
if report_path:
|
|
report_path_value = report_path
|
|
elif job_type in {"lineup", "record", "compare", "video_review"}:
|
|
report_path_value = str(build_generated_report_paths(game_id, job_type, inning_no)[0])
|
|
else:
|
|
report_path_value = str(OUTPUT_DIR / f"{game_id}_report.json")
|
|
try:
|
|
from db_logging import start_job as db_start_job
|
|
db_start_job(job_id=job_id, game_id=game_id, start_inning=inning_no or "")
|
|
except Exception as e:
|
|
print(f"DB logging init error: {e}")
|
|
|
|
job = {
|
|
"job_id": job_id,
|
|
"type": job_type,
|
|
"game_id": game_id,
|
|
"manager_game_no": manager_game_no or "",
|
|
"report_path": report_path_value,
|
|
"inning_no": inning_no or "",
|
|
"status": "queued",
|
|
"created_at": now_iso(),
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
"log_file": str(log_path(job_id)),
|
|
"error": None,
|
|
"return_code": None,
|
|
}
|
|
save_job(job)
|
|
thread = threading.Thread(target=run_job, args=(job_id,), daemon=True)
|
|
thread.start()
|
|
return job
|
|
|
|
|
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
|
|
|
|
|
@app.get("/")
|
|
def index():
|
|
jobs = [enrich_job(job) for job in list_jobs()]
|
|
active_job = get_active_job()
|
|
recent_reports = list_recent_reports()
|
|
error = request.args.get("error", "").strip()
|
|
message = request.args.get("message", "").strip()
|
|
def split_inning(val: str):
|
|
if not val: return "1", "T"
|
|
m = re.match(r"^(\d+)([TB])$", val.upper())
|
|
if m: return m.group(1), m.group(2)
|
|
return val, "T"
|
|
|
|
start_num, start_half = split_inning(request.args.get("start_inning") or "1T")
|
|
end_num, end_half = split_inning(request.args.get("end_inning") or "9B")
|
|
|
|
defaults = {
|
|
"game_id_mode": (request.args.get("game_id_mode") or "parse").strip(),
|
|
"game_id": (request.args.get("game_id") or "").strip(),
|
|
"game_type": (request.args.get("game_type") or "regular").strip(),
|
|
"game_date": (request.args.get("game_date") or "").strip(),
|
|
"home_team_code": (request.args.get("home_team_code") or "").strip(),
|
|
"away_team_code": (request.args.get("away_team_code") or "").strip(),
|
|
"doubleheader_no": (request.args.get("doubleheader_no") or "0").strip(),
|
|
"manager_mode": (request.args.get("manager_mode") or "auto").strip(),
|
|
"manager_game_no": (request.args.get("manager_game_no") or "").strip(),
|
|
"report_path": (request.args.get("report_path") or "").strip(),
|
|
"inning_no": (request.args.get("inning_no") or "1T-9B").strip(),
|
|
"start_inning_num": start_num,
|
|
"start_inning_half": start_half,
|
|
"end_inning_num": end_num,
|
|
"end_inning_half": end_half,
|
|
}
|
|
return render_template(
|
|
"index.html",
|
|
jobs=jobs,
|
|
defaults=defaults,
|
|
error=error,
|
|
message=message,
|
|
active_job=active_job,
|
|
recent_reports=recent_reports,
|
|
team_options=TEAM_OPTIONS,
|
|
game_type_options=GAME_TYPE_OPTIONS,
|
|
)
|
|
|
|
|
|
@app.post("/jobs/<job_type>")
|
|
def start_job(job_type: str):
|
|
if job_type not in {"register_basic", "lineup", "record", "finish", "post_update", "compare", "video_review"}:
|
|
abort(404)
|
|
|
|
game_id = resolve_game_id_from_form(request.form)
|
|
manager_mode = (request.form.get("manager_mode") or "auto").strip()
|
|
manager_game_no = (request.form.get("manager_game_no") or "").strip() if manager_mode == "manual" else ""
|
|
report_path = (request.form.get("report_path") or "").strip()
|
|
inning_no = (request.form.get("inning_no") or "").strip()
|
|
redirect_params = {
|
|
"game_id_mode": (request.form.get("game_id_mode") or "direct").strip(),
|
|
"game_id": (request.form.get("game_id") or "").strip(),
|
|
"game_type": (request.form.get("game_type") or "regular").strip(),
|
|
"game_date": (request.form.get("game_date") or "").strip(),
|
|
"home_team_code": (request.form.get("home_team_code") or "").strip(),
|
|
"away_team_code": (request.form.get("away_team_code") or "").strip(),
|
|
"doubleheader_no": (request.form.get("doubleheader_no") or "0").strip(),
|
|
"manager_mode": manager_mode,
|
|
"manager_game_no": manager_game_no,
|
|
"report_path": report_path,
|
|
"inning_no": inning_no,
|
|
"start_inning": (request.form.get("start_inning") or "1").strip(),
|
|
"end_inning": (request.form.get("end_inning") or "9").strip(),
|
|
}
|
|
|
|
validation_error = validate_job_request(job_type, game_id, manager_mode, manager_game_no, report_path, inning_no)
|
|
if validation_error:
|
|
return redirect(url_for("index", error=validation_error, **redirect_params))
|
|
|
|
if has_active_job():
|
|
return redirect(url_for("index", error="실행 중인 작업이 있습니다. 완료 후 다시 시도하세요.", **redirect_params))
|
|
|
|
create_job(
|
|
job_type=job_type,
|
|
game_id=game_id,
|
|
manager_game_no=None if job_type == "register_basic" else (manager_game_no or None),
|
|
report_path=report_path or None,
|
|
inning_no=inning_no or None,
|
|
)
|
|
return redirect(url_for("index", **redirect_params))
|
|
|
|
|
|
def clear_files(paths: list[Path]) -> int:
|
|
deleted = 0
|
|
for path in paths:
|
|
if path.exists() and path.is_file():
|
|
path.unlink()
|
|
deleted += 1
|
|
return deleted
|
|
|
|
|
|
@app.post("/maintenance/clear-logs")
|
|
def clear_logs():
|
|
deleted = clear_files(list(LOGS_DIR.glob("*.log")))
|
|
return redirect(url_for("index", message=f"로그 {deleted}개를 삭제했습니다."))
|
|
|
|
|
|
@app.post("/maintenance/clear-jobs")
|
|
def clear_jobs():
|
|
deleted = clear_files(list(JOBS_DIR.glob("*.json")))
|
|
return redirect(url_for("index", message=f"작업 상태 {deleted}개를 삭제했습니다."))
|
|
|
|
|
|
@app.post("/maintenance/clear-reports")
|
|
def clear_reports():
|
|
targets = list(OUTPUT_DIR.glob("*_report.json")) + list(OUTPUT_DIR.glob("*_report.txt"))
|
|
deleted = clear_files(targets)
|
|
return redirect(url_for("index", message=f"리포트 {deleted}개를 삭제했습니다."))
|
|
|
|
|
|
@app.get("/db-logs/<job_id>")
|
|
def view_db_logs(job_id: str):
|
|
try:
|
|
from db_logging import get_combined_logs
|
|
logs = get_combined_logs(job_id)
|
|
except Exception as e:
|
|
return f"DB 로드 실패: {e}", 500
|
|
|
|
return render_template("logs.html", job_id=job_id, logs=logs)
|
|
|
|
|
|
|
|
@app.post("/maintenance/clear-runtime-profiles")
|
|
def clear_runtime_profiles():
|
|
runtime_dirs = [path for path in ROOT_DIR.glob("playwright-user-data-runtime-*") if path.is_dir()]
|
|
deleted = 0
|
|
for path in runtime_dirs:
|
|
try:
|
|
shutil.rmtree(path, ignore_errors=False)
|
|
deleted += 1
|
|
except Exception:
|
|
continue
|
|
return redirect(url_for("index", message=f"런타임 프로필 {deleted}개를 삭제했습니다."))
|
|
|
|
|
|
@app.get("/api/dashboard")
|
|
def dashboard_api():
|
|
jobs = [enrich_job(job) for job in list_jobs()]
|
|
active_job = get_active_job()
|
|
recent_reports = list_recent_reports()
|
|
return jsonify({
|
|
"jobs": jobs,
|
|
"active_job": active_job,
|
|
"recent_reports": recent_reports
|
|
})
|
|
|
|
|
|
@app.get("/jobs")
|
|
def jobs_api():
|
|
return jsonify(list_jobs())
|
|
|
|
|
|
@app.get("/jobs/<job_id>")
|
|
def job_detail(job_id: str):
|
|
try:
|
|
job = load_job(job_id)
|
|
except FileNotFoundError:
|
|
abort(404)
|
|
return render_template("job.html", job=job)
|
|
|
|
|
|
@app.get("/jobs/<job_id>/status")
|
|
def job_status(job_id: str):
|
|
try:
|
|
return jsonify(load_job(job_id))
|
|
except FileNotFoundError:
|
|
abort(404)
|
|
|
|
|
|
@app.get("/jobs/<job_id>/log")
|
|
def job_log(job_id: str):
|
|
path = log_path(job_id)
|
|
if not path.exists():
|
|
abort(404)
|
|
return path.read_text(encoding="utf-8"), 200, {"Content-Type": "text/plain; charset=utf-8"}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)
|