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, )