eight-hourr/backend/main.py

447 lines
13 KiB
Python
Raw Permalink Normal View History

2026-07-17 06:55:36 +00:00
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
from typing import Any, Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from config import reload_settings, settings
from services.compbase_client import CompBaseClient
from services.date_parser import parse_dates_text
from services.extension_packager import build_extension_zip
from services.fill_service import FillService
from services.pts_client import PTSClient
from services.scheduler import start_scheduler, stop_scheduler
from services.secret_box import encrypt_secret
from services.settings_api import DatesFilePayload, SettingsUpdatePayload
from services.settings_service import SettingsService
from services.settings_store import load_overrides, save_overrides
from services.state_store import (
clear_pts_session,
get_pts_session,
get_teams_browser_meta,
set_pts_session,
)
from services.teams_calendar_scraper import (
MFA_PREVIEW_FILE,
TeamsCalendarScraper,
browser_view_info,
)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
@asynccontextmanager
async def lifespan(_app: FastAPI):
from services.pts_client import normalize_pts_base_url
logger.info("PTS API base: %s", normalize_pts_base_url(settings.pts_url))
start_scheduler()
yield
stop_scheduler()
app = FastAPI(title="吉八小", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
class SessionPayload(BaseModel):
accessToken: str
refreshToken: Optional[str] = None
groupCode: Optional[str] = None
raw: Optional[dict[str, Any]] = None
class PtsLoginPayload(BaseModel):
username: str
password: str
remember: bool = True
group_code: Optional[str] = None
class TeamsLoginPayload(BaseModel):
username: str
password: str
remember: bool = True
headless: Optional[bool] = None
class FillRangePayload(BaseModel):
start_date: date
end_date: date
dry_run: bool = False
skip_existing: bool = True
class FillTodayPayload(BaseModel):
dry_run: bool = False
class DatesTextPayload(BaseModel):
dates_text: str
dry_run: bool = False
skip_existing: bool = True
class CompbaseScanPayload(BaseModel):
days: int = Field(default=14, ge=1, le=62)
class CompbaseFillPayload(BaseModel):
days: int = Field(default=14, ge=1, le=62)
dry_run: bool = False
out_time_mode: str = "expected"
@app.get("/api/extension/download")
async def download_extension():
try:
payload = build_extension_zip()
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return Response(
content=payload,
media_type="application/zip",
headers={
"Content-Disposition": 'attachment; filename="pts-session-sync.zip"',
},
)
@app.get("/")
async def index():
index_file = FRONTEND_DIR / "index.html"
if index_file.exists():
return FileResponse(index_file)
return {"message": "PTS backend running. Frontend not found."}
@app.get("/settings")
async def settings_page():
settings_file = FRONTEND_DIR / "settings.html"
if settings_file.exists():
return FileResponse(settings_file)
raise HTTPException(status_code=404, detail="Settings page not found")
@app.get("/compbase")
async def compbase_page():
page = FRONTEND_DIR / "compbase.html"
if page.exists():
return FileResponse(page)
raise HTTPException(status_code=404, detail="CompBase page not found")
@app.post("/api/session")
async def save_session(payload: SessionPayload):
session_data = payload.raw or {
"accessToken": payload.accessToken,
"refreshToken": payload.refreshToken,
"groupCode": payload.groupCode,
}
set_pts_session(session_data)
return {"ok": True, "message": "PTS session saved"}
@app.get("/api/session")
async def get_session_status():
session = get_pts_session()
return {
"has_session": bool(session and session.get("accessToken")),
"group_code": (session or {}).get("groupCode"),
}
@app.get("/api/gitlab/tasks")
async def list_gitlab_tasks():
service = FillService()
try:
return {"tasks": await service.fetch_gitlab_tasks()}
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/gitlab/sources")
async def list_gitlab_sources():
from services.gitlab_client import GitLabClient
try:
return await GitLabClient().get_task_sources()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/preview")
async def preview_plan(start_date: date, end_date: date):
service = FillService()
try:
return await service.preview_plan(start_date, end_date)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/fill/range")
async def fill_range(payload: FillRangePayload):
service = FillService()
try:
return await service.fill_dates(
payload.start_date,
payload.end_date,
skip_existing=payload.skip_existing,
dry_run=payload.dry_run,
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/settings")
async def get_settings():
service = SettingsService()
return service.get_public_settings()
@app.put("/api/settings")
async def update_settings(payload: SettingsUpdatePayload):
service = SettingsService()
try:
return service.save_settings(payload.to_update_dict())
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/dates-file")
async def get_dates_file():
service = SettingsService()
return {"content": service.get_dates_file()}
@app.put("/api/dates-file")
async def save_dates_file(payload: DatesFilePayload):
service = SettingsService()
service.save_dates_file(payload.content)
return {"ok": True, "message": "dates.txt saved"}
@app.post("/api/preview/dates")
async def preview_dates(payload: DatesTextPayload):
service = FillService()
days = parse_dates_text(payload.dates_text)
try:
return await service.preview_plan_for_days(days)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/fill/dates")
async def fill_dates(payload: DatesTextPayload):
service = FillService()
days = parse_dates_text(payload.dates_text)
try:
return await service.fill_date_list(
days,
dry_run=payload.dry_run,
skip_existing=payload.skip_existing,
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/fill/today")
async def fill_today(payload: FillTodayPayload = FillTodayPayload()):
service = FillService()
try:
return await service.fill_today(dry_run=payload.dry_run)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/pts/login")
async def pts_login(payload: PtsLoginPayload):
try:
if payload.remember:
overrides = load_overrides()
overrides["pts_username"] = payload.username.strip()
overrides["pts_password"] = encrypt_secret(payload.password)
save_overrides(overrides)
reload_settings()
result = await PTSClient().login(
payload.username,
payload.password,
group_code=payload.group_code,
)
return result
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/pts/logout")
async def pts_logout():
clear_pts_session()
return {"ok": True, "message": "PTS session cleared"}
@app.get("/api/teams/status")
async def teams_status():
status = TeamsCalendarScraper().status()
status["browser_view"] = browser_view_info()
return status
@app.get("/api/teams/browser-view")
async def teams_browser_view():
"""Docker noVNC / local window info for interactive MFA."""
info = browser_view_info()
meta = get_teams_browser_meta() or {}
info["login_message"] = meta.get("message")
info["mfa_page_url"] = meta.get("mfa_page_url")
info["mfa_preview_label"] = meta.get("mfa_preview_label")
return info
@app.get("/api/teams/mfa-preview")
async def teams_mfa_preview():
"""Latest screenshot of the login/MFA Chromium page (refreshed while waiting)."""
if not MFA_PREVIEW_FILE.exists() or MFA_PREVIEW_FILE.stat().st_size < 50:
raise HTTPException(status_code=404, detail="尚無 MFA 預覽截圖(請先按登入)")
return FileResponse(
path=str(MFA_PREVIEW_FILE),
media_type="image/png",
headers={
"Cache-Control": "no-store, no-cache, must-revalidate",
"Pragma": "no-cache",
},
)
@app.post("/api/teams/login")
async def teams_login(payload: TeamsLoginPayload):
try:
if payload.remember:
overrides = load_overrides()
overrides["teams_username"] = payload.username.strip()
overrides["teams_password"] = encrypt_secret(payload.password)
overrides["teams_calendar_enabled"] = True
save_overrides(overrides)
reload_settings()
# Default: show browser for MFA (Docker Xvfb + noVNC, or local window)
headless = payload.headless if payload.headless is not None else False
result = await TeamsCalendarScraper().login(
payload.username,
payload.password,
headless=headless,
)
result["browser_view"] = browser_view_info()
return result
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/teams/logout")
async def teams_logout():
await TeamsCalendarScraper().logout()
return {"ok": True, "message": "Teams calendar session cleared"}
@app.get("/api/teams/meetings")
async def teams_meetings(
start_date: Optional[date] = None,
end_date: Optional[date] = None,
skip_holidays: bool = True,
debug: bool = False,
):
"""List Teams/Outlook meetings for the same date range as PTS fill.
By default skips weekends and Taiwan public holidays (same as fill).
Pass debug=1 for connection diagnostics (standalone Teams test UI/CLI).
"""
today = date.today()
start = start_date or today
end = end_date or start
try:
return await TeamsCalendarScraper().list_meetings(
start,
end,
skip_holidays=skip_holidays,
debug=debug,
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/status")
async def status():
service = FillService()
return await service.get_status()
@app.get("/api/compbase/status")
async def compbase_status():
try:
return await CompBaseClient().status()
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/compbase/scan")
async def compbase_scan(payload: CompbaseScanPayload):
try:
return await CompBaseClient().list_missing(payload.days)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.post("/api/compbase/fill")
async def compbase_fill(payload: CompbaseFillPayload):
mode = (payload.out_time_mode or "expected").strip().lower()
if mode not in {"expected", "latest_option"}:
raise HTTPException(
status_code=400,
detail="out_time_mode must be 'expected' or 'latest_option'",
)
try:
return await CompBaseClient().fill_recent(
payload.days,
dry_run=payload.dry_run,
out_time_mode=mode,
)
except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get("/api/config")
async def get_config():
return SettingsService().get_public_settings()
if __name__ == "__main__":
import os
import uvicorn
reload = os.environ.get("PTS_RELOAD", "true").lower() in ("1", "true", "yes")
uvicorn.run(
"main:app",
host=settings.backend_host,
port=settings.backend_port,
reload=reload,
)