447 lines
13 KiB
Python
447 lines
13 KiB
Python
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,
|
|
) |