init
This commit is contained in:
commit
863c92cad9
|
|
@ -0,0 +1,56 @@
|
||||||
|
# --- secrets / env (never bake into image) ---
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/.secret_key
|
||||||
|
**/settings.json
|
||||||
|
**/state.json
|
||||||
|
**/teams_browser_state.json
|
||||||
|
|
||||||
|
# --- local runtime data ---
|
||||||
|
backend/data/
|
||||||
|
pts-data/
|
||||||
|
*.log
|
||||||
|
*.pid
|
||||||
|
server.log
|
||||||
|
server.pid
|
||||||
|
dates.txt
|
||||||
|
|
||||||
|
# --- python / venv ---
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# --- VCS / IDE / OS ---
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# --- docs / host-only tooling (not needed in image) ---
|
||||||
|
README.md
|
||||||
|
Makefile
|
||||||
|
run.sh
|
||||||
|
scripts/
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.*.yml
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# --- local notes / extras ---
|
||||||
|
.claude/
|
||||||
|
.grok/
|
||||||
|
*.session.sql
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
# GitLab
|
||||||
|
GITLAB_URL=https://gitlab.supermicro.com
|
||||||
|
GITLAB_TOKEN=your_gitlab_personal_access_token
|
||||||
|
GITLAB_PROJECT_PATH=super-cloud/scc-flex
|
||||||
|
GITLAB_BOARD_ID=1126
|
||||||
|
# Comma-separated labels for board bug column
|
||||||
|
GITLAB_BOARD_LABELS=Category::BugFix,Type::Bug
|
||||||
|
# 留空則自動選目前衝刺(最新 closed 版之後最小的 active,例如 a11)
|
||||||
|
GITLAB_MILESTONE_TITLE=
|
||||||
|
|
||||||
|
# PTS
|
||||||
|
PTS_URL=https://tw-timesheet.supermicro.com/PTS
|
||||||
|
PTS_PROJECT_NAME=SuperCloud Composer
|
||||||
|
PTS_DEFAULT_TASK_NAME=Implement
|
||||||
|
|
||||||
|
# Scheduler (24h, server local time)
|
||||||
|
AUTO_FILL_ENABLED=true
|
||||||
|
AUTO_FILL_HOUR=18
|
||||||
|
AUTO_FILL_MINUTE=0
|
||||||
|
|
||||||
|
# xAI Grok LLM
|
||||||
|
XAI_API_KEY=your_xai_api_key
|
||||||
|
XAI_BASE_URL=https://api.x.ai
|
||||||
|
GROK_MODEL=grok-3-mini
|
||||||
|
USE_LLM=true
|
||||||
|
|
||||||
|
# Microsoft Teams / Outlook calendar via web scrape (optional)
|
||||||
|
TEAMS_CALENDAR_ENABLED=false
|
||||||
|
TEAMS_CALENDAR_TIMEZONE=Asia/Taipei
|
||||||
|
TEAMS_MEETING_TASK_NAME=Meeting
|
||||||
|
# Prefer logging in via Web UI (passwords are encrypted on disk)
|
||||||
|
# TEAMS_USERNAME=
|
||||||
|
# TEAMS_PASSWORD=
|
||||||
|
# TEAMS_HEADLESS=false
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
BACKEND_HOST=0.0.0.0
|
||||||
|
BACKEND_PORT=8765
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
# secrets / env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
**/.secret_key
|
||||||
|
|
||||||
|
# runtime state (local + docker host mounts)
|
||||||
|
backend/data/state.json
|
||||||
|
backend/data/settings.json
|
||||||
|
backend/data/server.pid
|
||||||
|
backend/data/server.log
|
||||||
|
backend/data/teams_browser_state.json
|
||||||
|
pts-data/
|
||||||
|
!pts-data/.gitkeep
|
||||||
|
!pts-data/README.txt
|
||||||
|
|
||||||
|
# python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# OS / IDE
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Playwright/Chromium system deps + virtual display (Xvfb) + noVNC for interactive MFA
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||||
|
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||||
|
libgbm1 libasound2 libpango-1.0-0 libcairo2 libatspi2.0-0 \
|
||||||
|
fonts-liberation ca-certificates \
|
||||||
|
xvfb x11vnc novnc websockify fluxbox \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install deps first (better layer cache). Context is filtered by .dockerignore.
|
||||||
|
COPY backend/requirements.txt backend/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r backend/requirements.txt \
|
||||||
|
&& playwright install chromium
|
||||||
|
|
||||||
|
# App only — no .env, no backend/data, no pts-data, no host secrets (see .dockerignore)
|
||||||
|
COPY backend/ backend/
|
||||||
|
COPY frontend/ frontend/
|
||||||
|
COPY extension/ extension/
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
COPY .env.example .env.example
|
||||||
|
|
||||||
|
# Ensure no accidental local data was copied (belt-and-suspenders)
|
||||||
|
RUN rm -rf /app/backend/data \
|
||||||
|
&& mkdir -p /app/backend/data \
|
||||||
|
&& chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENV PTS_DATA_DIR=/data
|
||||||
|
ENV PTS_RELOAD=false
|
||||||
|
ENV PTS_IN_DOCKER=true
|
||||||
|
# Headed Chromium on Xvfb for MFA; login API still requests headless=false
|
||||||
|
ENV TEAMS_HEADLESS=false
|
||||||
|
ENV DISPLAY=:99
|
||||||
|
ENV TEAMS_VNC_PORT=6080
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
# Playwright/Chromium in container
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/root/.cache/ms-playwright
|
||||||
|
|
||||||
|
EXPOSE 8765 6080
|
||||||
|
|
||||||
|
WORKDIR /app/backend
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8765"]
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
PYTHON := .venv/bin/python
|
||||||
|
PIP := .venv/bin/pip
|
||||||
|
TODAY := $(shell date +%Y-%m-%d)
|
||||||
|
|
||||||
|
.PHONY: help setup init status preview preview-dates dry-run dry-run-dates fill fill-range fill-dates teams-test start stop restart server-status start-fg docker-build docker-up docker-down docker-logs docker-restart docker-start
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "吉八小 · 集滿八小時"
|
||||||
|
@echo ""
|
||||||
|
@echo " make setup 建立 venv + 安裝依賴 + 建立 .env"
|
||||||
|
@echo " make status 檢查設定 / Session / 任務數"
|
||||||
|
@echo " make preview 用 Grok 預覽今天的填寫計畫"
|
||||||
|
@echo " make dry-run 試跑今天(不送出 PTS)"
|
||||||
|
@echo " make fill 一鍵填寫今天"
|
||||||
|
@echo " make fill-range START=... END=... 補寫日期區間"
|
||||||
|
@echo " make fill-dates 依 dates.txt 補寫指定日期"
|
||||||
|
@echo " make preview-dates 預覽 dates.txt 的分配"
|
||||||
|
@echo " make teams-test [START=] [END=] 只測 Teams 抓會議(不寫 PTS)"
|
||||||
|
@echo " make start 背景啟動 Web UI + API"
|
||||||
|
@echo " make stop 關閉伺服器"
|
||||||
|
@echo " make restart 重啟伺服器"
|
||||||
|
@echo " make server-status 查看伺服器是否在跑"
|
||||||
|
@echo " make start-fg 前景啟動(開發用,Ctrl+C 結束)"
|
||||||
|
@echo " make docker-start 一鍵:關本地 + 建置 + 啟動 Docker"
|
||||||
|
@echo " make docker-build 建置 Docker 映像"
|
||||||
|
@echo " make docker-up 啟動 Docker(無主機掛載,/data 為 tmpfs,關容器即消失)"
|
||||||
|
@echo " make docker-down 停止 Docker"
|
||||||
|
@echo " make docker-logs 查看 Docker log"
|
||||||
|
@echo ""
|
||||||
|
@echo "第一次請先:"
|
||||||
|
@echo " 1) 編輯 .env 填入 GITLAB_TOKEN / XAI_API_KEY"
|
||||||
|
@echo " 2) make start → 網頁輸入 PTS(與選用 Teams)帳密登入"
|
||||||
|
@echo " 3) make fill"
|
||||||
|
|
||||||
|
setup: init
|
||||||
|
python3 -m venv .venv
|
||||||
|
$(PIP) install -r backend/requirements.txt
|
||||||
|
$(PYTHON) -m playwright install chromium
|
||||||
|
@echo "Setup done. Edit .env then open http://localhost:8765 to login with PTS / Teams accounts."
|
||||||
|
|
||||||
|
init:
|
||||||
|
@test -f .env || cp .env.example .env
|
||||||
|
|
||||||
|
status:
|
||||||
|
cd backend && ../$(PYTHON) cli.py status
|
||||||
|
|
||||||
|
preview:
|
||||||
|
cd backend && ../$(PYTHON) cli.py preview --start $(TODAY) --end $(TODAY)
|
||||||
|
|
||||||
|
dry-run:
|
||||||
|
cd backend && ../$(PYTHON) cli.py fill-today --dry-run
|
||||||
|
|
||||||
|
fill:
|
||||||
|
cd backend && ../$(PYTHON) cli.py fill-today
|
||||||
|
|
||||||
|
fill-range:
|
||||||
|
@test -n "$(START)" && test -n "$(END)" || (echo "Usage: make fill-range START=YYYY-MM-DD END=YYYY-MM-DD" && exit 1)
|
||||||
|
cd backend && ../$(PYTHON) cli.py fill-range --start $(START) --end $(END)
|
||||||
|
|
||||||
|
preview-dates:
|
||||||
|
cd backend && ../$(PYTHON) cli.py preview-dates --file ../dates.txt
|
||||||
|
|
||||||
|
dry-run-dates:
|
||||||
|
cd backend && ../$(PYTHON) cli.py fill-dates --file ../dates.txt --dry-run
|
||||||
|
|
||||||
|
fill-dates:
|
||||||
|
cd backend && ../$(PYTHON) cli.py fill-dates --file ../dates.txt
|
||||||
|
|
||||||
|
teams-test:
|
||||||
|
cd backend && ../$(PYTHON) cli.py teams-test $(if $(START),--start $(START),) $(if $(END),--end $(END),) $(if $(INCLUDE_HOLIDAYS),--include-holidays,)
|
||||||
|
|
||||||
|
start:
|
||||||
|
@chmod +x scripts/server.sh
|
||||||
|
@./scripts/server.sh start
|
||||||
|
|
||||||
|
stop:
|
||||||
|
@chmod +x scripts/server.sh
|
||||||
|
@./scripts/server.sh stop
|
||||||
|
|
||||||
|
restart:
|
||||||
|
@chmod +x scripts/server.sh
|
||||||
|
@./scripts/server.sh restart
|
||||||
|
|
||||||
|
server-status:
|
||||||
|
@chmod +x scripts/server.sh
|
||||||
|
@./scripts/server.sh status
|
||||||
|
|
||||||
|
start-fg:
|
||||||
|
cd backend && ../$(PYTHON) main.py
|
||||||
|
|
||||||
|
docker-start:
|
||||||
|
@chmod +x scripts/docker-start.sh
|
||||||
|
@./scripts/docker-start.sh
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
docker compose build --pull
|
||||||
|
|
||||||
|
docker-up:
|
||||||
|
@echo "Ephemeral mode: no host data mount (tmpfs /data only)"
|
||||||
|
docker compose up -d --build --force-recreate --remove-orphans
|
||||||
|
@echo "Web UI: http://localhost:$${BACKEND_PORT:-8765}"
|
||||||
|
@echo "資料僅在容器內,docker compose down 後即消失"
|
||||||
|
|
||||||
|
docker-down:
|
||||||
|
docker compose down --remove-orphans
|
||||||
|
|
||||||
|
docker-logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
docker-restart:
|
||||||
|
docker compose up -d --force-recreate --build --remove-orphans
|
||||||
|
|
||||||
|
# Stop local Python server + Docker container
|
||||||
|
stop-all:
|
||||||
|
@chmod +x scripts/server.sh
|
||||||
|
@./scripts/server.sh stop 2>/dev/null || true
|
||||||
|
@docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
@echo "All local + Docker services stopped"
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
# 吉八小
|
||||||
|
|
||||||
|
**集滿八小時** — 自動從 GitLab 任務看板抓取工作項目,用 **Grok LLM** 分析後填入 [PTS 工時系統](https://tw-timesheet.supermicro.com/PTS/)。可選併入 **Teams / Outlook 網頁行事曆**會議時數。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 網頁輸入 **PTS 帳密** → 直接打 PTS Login API(Windows/NTLM;可選 Forms)
|
||||||
|
- 可選:網頁輸入 **Microsoft 帳密** → 優先 Graph 帳密 API;MFA 時改瀏覽器登入,之後仍打行事曆 API
|
||||||
|
- 從 GitLab 抓取任務(看板 Bug + 目前衝刺 milestone)
|
||||||
|
- **Grok** 產生專業英文 Description 與時數分配
|
||||||
|
- 每日最多 **8 小時**(0.5h 單位)
|
||||||
|
- Chrome Extension **備援**同步 PTS Token
|
||||||
|
- **CompBase 刷卡補填**(獨立分頁 `/compbase`):NTLM 登入出勤系統 → 掃「應刷未刷」→ 一鍵補刷退
|
||||||
|
|
||||||
|
## 架構
|
||||||
|
|
||||||
|
```
|
||||||
|
Web UI 帳密 → PTS Windows/NTLM Login → Backend
|
||||||
|
Web UI 帳密 → Playwright / Outlook 爬蟲 → 會議時數
|
||||||
|
GitLab API → 任務 ↗
|
||||||
|
Grok API → 分析填寫計畫 ↗
|
||||||
|
(備援)Chrome Extension → PTS Token
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
### 1. 安裝
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd pts
|
||||||
|
make setup
|
||||||
|
```
|
||||||
|
|
||||||
|
會安裝 Python 依賴與 Playwright Chromium。
|
||||||
|
|
||||||
|
### 2. 編輯 `.env`
|
||||||
|
|
||||||
|
```env
|
||||||
|
GITLAB_TOKEN=你的_gitlab_token
|
||||||
|
XAI_API_KEY=你的_grok_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
其餘設定(PTS / Teams 帳密、專案名等)建議在 Web UI 操作;密碼會以 Fernet 加密寫入本機 `settings.json`。
|
||||||
|
|
||||||
|
### 3. 啟動並登入
|
||||||
|
|
||||||
|
**本機 Python:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker(不存本機資料,`/data` 用 tmpfs,關容器即消失):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make docker-start
|
||||||
|
# 或: make docker-up
|
||||||
|
```
|
||||||
|
|
||||||
|
可選:在專案根目錄 `.env` 放 `GITLAB_TOKEN` / `XAI_API_KEY` 等,compose 會注入容器環境變數(仍不會掛載資料目錄)。
|
||||||
|
|
||||||
|
開啟 http://localhost:8765
|
||||||
|
|
||||||
|
停止 Docker:`make docker-down`
|
||||||
|
|
||||||
|
1. **PTS**:輸入帳號(建議 `DOMAIN\user`)與密碼 → **登入 PTS**
|
||||||
|
2. **Teams**(選用):勾選啟用 → 輸入 Microsoft 帳密 → **登入並抓行事曆**
|
||||||
|
- 有 MFA 時會開瀏覽器視窗,請完成驗證
|
||||||
|
3. **進階設定**:GitLab Token / Grok / 專案 / 排程
|
||||||
|
|
||||||
|
### 4. 一鍵填寫
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make preview # 預覽
|
||||||
|
make dry-run # 試跑
|
||||||
|
make fill # 正式填今天
|
||||||
|
```
|
||||||
|
|
||||||
|
## Make 指令
|
||||||
|
|
||||||
|
| 指令 | 說明 |
|
||||||
|
|------|------|
|
||||||
|
| `make setup` | venv、依賴、Playwright Chromium |
|
||||||
|
| `make status` | 檢查設定與連線 |
|
||||||
|
| `make preview` / `make fill` | 預覽 / 填寫今天 |
|
||||||
|
| `make fill-range START=... END=...` | 補寫區間 |
|
||||||
|
| `make start` | 啟動 Web UI(http://localhost:8765) |
|
||||||
|
|
||||||
|
## 帳密與安全
|
||||||
|
|
||||||
|
- 密碼以 **Fernet** 加密存在本機資料目錄;金鑰為 `data/.secret_key`(或 Docker 的 `/data/.secret_key`)
|
||||||
|
- **僅供個人本機使用**,勿提交 settings / secret key 到 git
|
||||||
|
- PTS Token 過期時:先 Refresh,失敗則用儲存帳密重登
|
||||||
|
- Teams session 存在 `teams_browser_state.json`;過期會嘗試重登(若觸發 MFA 需再互動一次)
|
||||||
|
|
||||||
|
## 備援:Chrome Extension
|
||||||
|
|
||||||
|
若公司網路僅允許瀏覽器 Windows SSO、帳密 API 不可用:
|
||||||
|
|
||||||
|
1. 下載 UI 底部「備援」區塊的 Extension
|
||||||
|
2. 登入 PTS 網頁後同步 Token 到後端
|
||||||
|
|
||||||
|
## 常見問題
|
||||||
|
|
||||||
|
**PTS 登入失敗**
|
||||||
|
→ 確認在公司網路/VPN;帳號試 `DOMAIN\user`;或改用 Extension 備援。
|
||||||
|
|
||||||
|
**Teams / Docker 如何完成 MFA**
|
||||||
|
→ Docker 內建 **Xvfb + noVNC**:設定頁按「登入」會跳出 MFA 面板
|
||||||
|
- **互動畫面**(http://主機:6080):可點擊、輸入,跟真的 Chrome 一樣
|
||||||
|
- **即時截圖**:約每 1.5 秒更新,方便對 Authenticator 數字
|
||||||
|
→ 登入流程中瀏覽器會**保持開啟最多約 10 分鐘**,完成 MFA 前不會關掉。
|
||||||
|
→ 需映射埠 `6080`(`docker-compose` 已設定)。若 iframe 空白,用「新分頁開啟」。
|
||||||
|
|
||||||
|
**Missing PTS session**
|
||||||
|
→ 在 Web UI 重新登入 PTS,或用 Extension 同步。
|
||||||
|
|
||||||
|
**Project not found**
|
||||||
|
→ 進階設定中 `Project Name` 需與 PTS 下拉一致。
|
||||||
|
|
||||||
|
## 專案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
pts/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py
|
||||||
|
│ └── services/
|
||||||
|
│ ├── pts_client.py # PTS API + 帳密登入
|
||||||
|
│ ├── teams_calendar_scraper.py # Outlook 網頁爬蟲
|
||||||
|
│ ├── secret_box.py # 密碼加密
|
||||||
|
│ ├── gitlab_client.py
|
||||||
|
│ ├── grok_client.py
|
||||||
|
│ └── planner.py
|
||||||
|
├── extension/ # 備援憑證同步
|
||||||
|
├── frontend/ # Web UI
|
||||||
|
└── Makefile
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from services.date_parser import parse_dates_file, parse_dates_text
|
||||||
|
from services.fill_service import FillService
|
||||||
|
|
||||||
|
|
||||||
|
def _print_json(data: object) -> None:
|
||||||
|
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_dates(args: argparse.Namespace) -> list[date]:
|
||||||
|
if args.file:
|
||||||
|
return parse_dates_file(args.file)
|
||||||
|
if args.dates:
|
||||||
|
return parse_dates_text(args.dates)
|
||||||
|
raise ValueError("Provide --file or --dates")
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_status(_: argparse.Namespace) -> int:
|
||||||
|
service = FillService()
|
||||||
|
_print_json(await service.get_status())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_teams_test(args: argparse.Namespace) -> int:
|
||||||
|
"""Standalone Teams calendar fetch test (no PTS / GitLab write)."""
|
||||||
|
from services.teams_calendar_scraper import TeamsCalendarScraper
|
||||||
|
|
||||||
|
start = date.fromisoformat(args.start) if args.start else date.today()
|
||||||
|
end = date.fromisoformat(args.end) if args.end else start
|
||||||
|
scraper = TeamsCalendarScraper()
|
||||||
|
result = await scraper.list_meetings(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
skip_holidays=not args.include_holidays,
|
||||||
|
debug=True,
|
||||||
|
)
|
||||||
|
_print_json(result)
|
||||||
|
if result.get("error"):
|
||||||
|
return 1
|
||||||
|
if (result.get("total_count") or 0) == 0:
|
||||||
|
print(
|
||||||
|
"\nHINT: 0 meetings. Login Teams via Web UI Settings (MFA), then retry.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_preview(args: argparse.Namespace) -> int:
|
||||||
|
service = FillService()
|
||||||
|
start = date.fromisoformat(args.start)
|
||||||
|
end = date.fromisoformat(args.end)
|
||||||
|
_print_json(await service.preview_plan(start, end, use_llm=not args.no_llm))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_preview_dates(args: argparse.Namespace) -> int:
|
||||||
|
service = FillService()
|
||||||
|
days = _resolve_dates(args)
|
||||||
|
_print_json(await service.preview_plan_for_days(days, use_llm=not args.no_llm))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_fill_today(args: argparse.Namespace) -> int:
|
||||||
|
service = FillService()
|
||||||
|
try:
|
||||||
|
service.ensure_ready()
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
result = await service.fill_today(dry_run=args.dry_run, use_llm=not args.no_llm)
|
||||||
|
_print_json(result)
|
||||||
|
return 0 if result.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_fill_range(args: argparse.Namespace) -> int:
|
||||||
|
service = FillService()
|
||||||
|
try:
|
||||||
|
service.ensure_ready()
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
start = date.fromisoformat(args.start)
|
||||||
|
end = date.fromisoformat(args.end)
|
||||||
|
result = await service.fill_dates(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
skip_existing=not args.force,
|
||||||
|
use_llm=not args.no_llm,
|
||||||
|
)
|
||||||
|
_print_json(result)
|
||||||
|
return 0 if result.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_fill_dates(args: argparse.Namespace) -> int:
|
||||||
|
service = FillService()
|
||||||
|
try:
|
||||||
|
service.ensure_ready()
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
days = _resolve_dates(args)
|
||||||
|
result = await service.fill_date_list(
|
||||||
|
days,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
skip_existing=not args.force,
|
||||||
|
use_llm=not args.no_llm,
|
||||||
|
)
|
||||||
|
_print_json(result)
|
||||||
|
return 0 if result.get("ok") else 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="PTS auto-fill CLI")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sub.add_parser("status", help="Show system status")
|
||||||
|
|
||||||
|
preview = sub.add_parser("preview", help="Preview fill plan for date range")
|
||||||
|
preview.add_argument("--start", required=True, help="YYYY-MM-DD")
|
||||||
|
preview.add_argument("--end", required=True, help="YYYY-MM-DD")
|
||||||
|
preview.add_argument("--no-llm", action="store_true")
|
||||||
|
|
||||||
|
preview_dates = sub.add_parser("preview-dates", help="Preview fill plan for date list")
|
||||||
|
preview_dates.add_argument("--file", help="Path to dates.txt")
|
||||||
|
preview_dates.add_argument("--dates", help="Comma/newline separated dates")
|
||||||
|
preview_dates.add_argument("--no-llm", action="store_true")
|
||||||
|
|
||||||
|
fill_today = sub.add_parser("fill-today", help="Fill today's PTS entries")
|
||||||
|
fill_today.add_argument("--dry-run", action="store_true")
|
||||||
|
fill_today.add_argument("--no-llm", action="store_true")
|
||||||
|
|
||||||
|
fill_range = sub.add_parser("fill-range", help="Fill PTS entries for date range")
|
||||||
|
fill_range.add_argument("--start", required=True, help="YYYY-MM-DD")
|
||||||
|
fill_range.add_argument("--end", required=True, help="YYYY-MM-DD")
|
||||||
|
fill_range.add_argument("--dry-run", action="store_true")
|
||||||
|
fill_range.add_argument("--force", action="store_true")
|
||||||
|
fill_range.add_argument("--no-llm", action="store_true")
|
||||||
|
|
||||||
|
fill_dates = sub.add_parser("fill-dates", help="Fill PTS entries for explicit date list")
|
||||||
|
fill_dates.add_argument("--file", help="Path to dates.txt")
|
||||||
|
fill_dates.add_argument("--dates", help="Comma/newline separated dates")
|
||||||
|
fill_dates.add_argument("--dry-run", action="store_true")
|
||||||
|
fill_dates.add_argument("--force", action="store_true")
|
||||||
|
fill_dates.add_argument("--no-llm", action="store_true")
|
||||||
|
|
||||||
|
teams_test = sub.add_parser(
|
||||||
|
"teams-test",
|
||||||
|
help="Standalone test: fetch Teams/Outlook meetings only (no PTS write)",
|
||||||
|
)
|
||||||
|
teams_test.add_argument("--start", help="YYYY-MM-DD (default: today)")
|
||||||
|
teams_test.add_argument("--end", help="YYYY-MM-DD (default: start)")
|
||||||
|
teams_test.add_argument(
|
||||||
|
"--include-holidays",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not skip weekends / TW holidays",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
handlers = {
|
||||||
|
"status": cmd_status,
|
||||||
|
"preview": cmd_preview,
|
||||||
|
"preview-dates": cmd_preview_dates,
|
||||||
|
"fill-today": cmd_fill_today,
|
||||||
|
"fill-range": cmd_fill_range,
|
||||||
|
"fill-dates": cmd_fill_dates,
|
||||||
|
"teams-test": cmd_teams_test,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await handlers[args.command](args)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(asyncio.run(main()))
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
from paths import DATA_DIR, EXTENSION_DIR, ROOT_DIR, get_dates_file_path, get_env_files
|
||||||
|
from services.settings_store import load_overrides, save_overrides
|
||||||
|
|
||||||
|
CONFIGURABLE_FIELDS = [
|
||||||
|
"gitlab_url",
|
||||||
|
"gitlab_token",
|
||||||
|
"gitlab_project_path",
|
||||||
|
"gitlab_board_id",
|
||||||
|
"gitlab_board_labels",
|
||||||
|
"gitlab_milestone_title",
|
||||||
|
"pts_url",
|
||||||
|
"pts_project_name",
|
||||||
|
"pts_default_task_name",
|
||||||
|
"auto_fill_enabled",
|
||||||
|
"auto_fill_hour",
|
||||||
|
"auto_fill_minute",
|
||||||
|
"max_hours_per_day",
|
||||||
|
"hour_step",
|
||||||
|
"xai_api_key",
|
||||||
|
"xai_base_url",
|
||||||
|
"grok_model",
|
||||||
|
"use_llm",
|
||||||
|
"use_description_variation",
|
||||||
|
"pts_username",
|
||||||
|
"pts_password",
|
||||||
|
"teams_username",
|
||||||
|
"teams_password",
|
||||||
|
"teams_calendar_enabled",
|
||||||
|
"teams_calendar_timezone",
|
||||||
|
"teams_meeting_task_name",
|
||||||
|
"teams_headless",
|
||||||
|
"teams_meeting_exclude_keywords",
|
||||||
|
# CompBase attendance punch (independent of PTS)
|
||||||
|
"compbase_list_url",
|
||||||
|
"compbase_fill_url",
|
||||||
|
"compbase_username",
|
||||||
|
"compbase_password",
|
||||||
|
"compbase_default_days",
|
||||||
|
"compbase_out_time_mode",
|
||||||
|
# legacy Graph fields kept for backward-compatible settings files
|
||||||
|
"azure_client_id",
|
||||||
|
"azure_tenant_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class EnvSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=get_env_files() or None,
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
gitlab_url: str = "https://gitlab.supermicro.com"
|
||||||
|
gitlab_token: str = ""
|
||||||
|
gitlab_project_path: str = "super-cloud/scc-flex"
|
||||||
|
gitlab_board_id: int = 1126
|
||||||
|
gitlab_board_labels: str = "Category::BugFix,Type::Bug"
|
||||||
|
gitlab_milestone_title: str = ""
|
||||||
|
|
||||||
|
pts_url: str = "https://tw-timesheet.supermicro.com/PTS"
|
||||||
|
pts_project_name: str = "SuperCloud Composer"
|
||||||
|
pts_default_task_name: str = "Implement"
|
||||||
|
pts_username: str = ""
|
||||||
|
pts_password: str = ""
|
||||||
|
|
||||||
|
auto_fill_enabled: bool = True
|
||||||
|
auto_fill_hour: int = 18
|
||||||
|
auto_fill_minute: int = 0
|
||||||
|
|
||||||
|
backend_host: str = "0.0.0.0"
|
||||||
|
backend_port: int = 8765
|
||||||
|
|
||||||
|
max_hours_per_day: float = 8.0
|
||||||
|
hour_step: float = 0.5
|
||||||
|
|
||||||
|
xai_api_key: str = ""
|
||||||
|
xai_base_url: str = "https://api.x.ai"
|
||||||
|
grok_model: str = "grok-3-mini"
|
||||||
|
use_llm: bool = True
|
||||||
|
use_description_variation: bool = True
|
||||||
|
|
||||||
|
teams_calendar_enabled: bool = False
|
||||||
|
teams_username: str = ""
|
||||||
|
teams_password: str = ""
|
||||||
|
teams_calendar_timezone: str = "Asia/Taipei"
|
||||||
|
teams_meeting_task_name: str = "Meeting"
|
||||||
|
teams_headless: bool = False
|
||||||
|
teams_meeting_exclude_keywords: str = "貼心的提醒,請記得下班刷退"
|
||||||
|
azure_client_id: str = ""
|
||||||
|
azure_tenant_id: str = "organizations"
|
||||||
|
|
||||||
|
compbase_list_url: str = "http://tw-compbase.supermicro.com:6699"
|
||||||
|
compbase_fill_url: str = "http://tw-compbase.supermicro.com:6671"
|
||||||
|
compbase_username: str = ""
|
||||||
|
compbase_password: str = ""
|
||||||
|
compbase_default_days: int = 14
|
||||||
|
compbase_out_time_mode: str = "expected"
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(BaseModel):
|
||||||
|
gitlab_url: str = "https://gitlab.supermicro.com"
|
||||||
|
gitlab_token: str = ""
|
||||||
|
gitlab_project_path: str = "super-cloud/scc-flex"
|
||||||
|
gitlab_board_id: int = 1126
|
||||||
|
gitlab_board_labels: str = "Category::BugFix,Type::Bug"
|
||||||
|
gitlab_milestone_title: str = ""
|
||||||
|
|
||||||
|
pts_url: str = "https://tw-timesheet.supermicro.com/PTS"
|
||||||
|
pts_project_name: str = "SuperCloud Composer"
|
||||||
|
pts_default_task_name: str = "Implement"
|
||||||
|
pts_username: str = ""
|
||||||
|
pts_password: str = ""
|
||||||
|
|
||||||
|
auto_fill_enabled: bool = True
|
||||||
|
auto_fill_hour: int = 18
|
||||||
|
auto_fill_minute: int = 0
|
||||||
|
|
||||||
|
backend_host: str = "0.0.0.0"
|
||||||
|
backend_port: int = 8765
|
||||||
|
|
||||||
|
max_hours_per_day: float = 8.0
|
||||||
|
hour_step: float = 0.5
|
||||||
|
|
||||||
|
xai_api_key: str = ""
|
||||||
|
xai_base_url: str = "https://api.x.ai"
|
||||||
|
grok_model: str = "grok-3-mini"
|
||||||
|
use_llm: bool = True
|
||||||
|
use_description_variation: bool = True
|
||||||
|
|
||||||
|
teams_calendar_enabled: bool = False
|
||||||
|
teams_username: str = ""
|
||||||
|
teams_password: str = ""
|
||||||
|
teams_calendar_timezone: str = "Asia/Taipei"
|
||||||
|
teams_meeting_task_name: str = "Meeting"
|
||||||
|
teams_headless: bool = False
|
||||||
|
teams_meeting_exclude_keywords: str = "貼心的提醒,請記得下班刷退"
|
||||||
|
azure_client_id: str = ""
|
||||||
|
azure_tenant_id: str = "organizations"
|
||||||
|
|
||||||
|
compbase_list_url: str = "http://tw-compbase.supermicro.com:6699"
|
||||||
|
compbase_fill_url: str = "http://tw-compbase.supermicro.com:6671"
|
||||||
|
compbase_username: str = ""
|
||||||
|
compbase_password: str = ""
|
||||||
|
compbase_default_days: int = 14
|
||||||
|
compbase_out_time_mode: str = "expected"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def teams_meeting_exclude_keyword_list(self) -> list[str]:
|
||||||
|
return [part.strip() for part in self.teams_meeting_exclude_keywords.split(",") if part.strip()]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gitlab_board_label_list(self) -> list[str]:
|
||||||
|
return [part.strip() for part in self.gitlab_board_labels.split(",") if part.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
_env = EnvSettings()
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings() -> AppSettings:
|
||||||
|
data = _env.model_dump()
|
||||||
|
overrides = load_overrides()
|
||||||
|
for key in CONFIGURABLE_FIELDS:
|
||||||
|
if key in overrides and overrides[key] is not None:
|
||||||
|
data[key] = overrides[key]
|
||||||
|
return AppSettings(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_dict() -> dict[str, Any]:
|
||||||
|
return build_settings().model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
def update_settings(updates: dict[str, Any]) -> AppSettings:
|
||||||
|
current = load_overrides()
|
||||||
|
merged = {**current}
|
||||||
|
for key in CONFIGURABLE_FIELDS:
|
||||||
|
if key not in updates:
|
||||||
|
continue
|
||||||
|
value = updates[key]
|
||||||
|
if value is not None:
|
||||||
|
merged[key] = value
|
||||||
|
save_overrides(merged)
|
||||||
|
return reload_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def reload_settings() -> AppSettings:
|
||||||
|
global _runtime
|
||||||
|
_runtime = build_settings()
|
||||||
|
return _runtime
|
||||||
|
|
||||||
|
|
||||||
|
_runtime = build_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsProxy:
|
||||||
|
def __getattr__(self, name: str) -> Any:
|
||||||
|
return getattr(_runtime, name)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"SettingsProxy({_runtime!r})"
|
||||||
|
|
||||||
|
|
||||||
|
settings = SettingsProxy()
|
||||||
|
|
@ -0,0 +1,447 @@
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
_PTS_DATA_DIR = os.environ.get("PTS_DATA_DIR", "").strip()
|
||||||
|
DATA_DIR = Path(_PTS_DATA_DIR) if _PTS_DATA_DIR else Path(__file__).resolve().parent / "data"
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
EXTENSION_DIR = ROOT_DIR / "extension"
|
||||||
|
|
||||||
|
|
||||||
|
def get_env_files() -> tuple[str, ...]:
|
||||||
|
candidates = [DATA_DIR / ".env", ROOT_DIR / ".env"]
|
||||||
|
return tuple(str(path) for path in candidates if path.exists())
|
||||||
|
|
||||||
|
|
||||||
|
def get_dates_file_path() -> Path:
|
||||||
|
if _PTS_DATA_DIR:
|
||||||
|
return DATA_DIR / "dates.txt"
|
||||||
|
return ROOT_DIR / "dates.txt"
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.32.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
httpx-ntlm>=1.4.0
|
||||||
|
apscheduler>=3.10.4
|
||||||
|
holidays>=0.57
|
||||||
|
python-dotenv>=1.0.1
|
||||||
|
pydantic>=2.9.0
|
||||||
|
pydantic-settings>=2.6.0
|
||||||
|
cryptography>=43.0.0
|
||||||
|
playwright>=1.49.0
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalendarMeeting:
|
||||||
|
subject: str
|
||||||
|
start: datetime
|
||||||
|
end: datetime
|
||||||
|
hours: float
|
||||||
|
is_online: bool
|
||||||
|
|
||||||
|
|
||||||
|
def format_meeting_description(meeting: CalendarMeeting) -> str:
|
||||||
|
label = "Teams meeting" if meeting.is_online else "Meeting"
|
||||||
|
return f"Attended {label}: {meeting.subject}."
|
||||||
|
|
@ -0,0 +1,550 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.secret_box import decrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_LIST_URL = "http://tw-compbase.supermicro.com:6699"
|
||||||
|
DEFAULT_FILL_URL = "http://tw-compbase.supermicro.com:6671"
|
||||||
|
|
||||||
|
_HIDDEN_FIELDS = (
|
||||||
|
"__VIEWSTATE",
|
||||||
|
"__VIEWSTATEGENERATOR",
|
||||||
|
"__EVENTVALIDATION",
|
||||||
|
"__VIEWSTATEENCRYPTED",
|
||||||
|
"__EVENTTARGET",
|
||||||
|
"__EVENTARGUMENT",
|
||||||
|
"__LASTFOCUS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AbnormalRow:
|
||||||
|
date: date
|
||||||
|
rid: str | None
|
||||||
|
exp_in: str
|
||||||
|
exp_out: str
|
||||||
|
actual_in: str
|
||||||
|
actual_out: str
|
||||||
|
status: str # missing_out | missing_in | other
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
data = asdict(self)
|
||||||
|
data["date"] = self.date.isoformat()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CompBaseClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
list_url: str | None = None,
|
||||||
|
fill_url: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.list_url = (list_url or getattr(settings, "compbase_list_url", "") or DEFAULT_LIST_URL).rstrip("/")
|
||||||
|
self.fill_url = (fill_url or getattr(settings, "compbase_fill_url", "") or DEFAULT_FILL_URL).rstrip("/")
|
||||||
|
|
||||||
|
def _credentials(self) -> tuple[str, str]:
|
||||||
|
username = (getattr(settings, "compbase_username", "") or "").strip()
|
||||||
|
encrypted = getattr(settings, "compbase_password", "") or ""
|
||||||
|
if not username or not encrypted:
|
||||||
|
username = (getattr(settings, "pts_username", "") or "").strip()
|
||||||
|
encrypted = getattr(settings, "pts_password", "") or ""
|
||||||
|
if not username or not encrypted:
|
||||||
|
raise ValueError(
|
||||||
|
"Missing CompBase credentials. Set CompBase or PTS username/password in Settings."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
password = decrypt_secret(encrypted)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Stored password could not be decrypted. Re-enter password in Settings.") from exc
|
||||||
|
if not password:
|
||||||
|
raise ValueError("CompBase password is empty. Re-enter password in Settings.")
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
def _auth(self):
|
||||||
|
try:
|
||||||
|
from httpx_ntlm import HttpNtlmAuth
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ValueError("httpx-ntlm is not installed. Run: pip install httpx-ntlm") from exc
|
||||||
|
username, password = self._credentials()
|
||||||
|
return HttpNtlmAuth(username, password)
|
||||||
|
|
||||||
|
def _make_client(self) -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
auth=self._auth(),
|
||||||
|
timeout=60.0,
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_hidden(html: str) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for name in _HIDDEN_FIELDS:
|
||||||
|
m = re.search(
|
||||||
|
rf'<input[^>]+name=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']',
|
||||||
|
html,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
m = re.search(
|
||||||
|
rf'<input[^>]+id=["\']{re.escape(name)}["\'][^>]*value=["\']([^"\']*)["\']',
|
||||||
|
html,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if m:
|
||||||
|
out[name] = m.group(1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_selected_selects(html: str) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for m in re.finditer(
|
||||||
|
r'<select[^>]*name=["\']([^"\']+)["\'][^>]*>([\s\S]*?)</select>',
|
||||||
|
html,
|
||||||
|
re.I,
|
||||||
|
):
|
||||||
|
name, body = m.group(1), m.group(2)
|
||||||
|
selected = re.search(
|
||||||
|
r'<option[^>]*selected[^>]*(?:value=["\']([^"\']*)["\'])?[^>]*>([^<]*)',
|
||||||
|
body,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if selected:
|
||||||
|
val = selected.group(1)
|
||||||
|
out[name] = val if val is not None else selected.group(2).strip()
|
||||||
|
continue
|
||||||
|
first = re.search(r'<option[^>]*value=["\']([^"\']*)["\']', body, re.I)
|
||||||
|
if first:
|
||||||
|
out[name] = first.group(1)
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_checked_radios(html: str) -> dict[str, str]:
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for m in re.finditer(r"<input([^>]+)/?>", html, re.I):
|
||||||
|
attrs = m.group(1)
|
||||||
|
typ = re.search(r'type=["\']([^"\']+)["\']', attrs, re.I)
|
||||||
|
if not typ or typ.group(1).lower() != "radio":
|
||||||
|
continue
|
||||||
|
if not re.search(r"\bchecked\b", attrs, re.I):
|
||||||
|
continue
|
||||||
|
nm = re.search(r'name=["\']([^"\']+)["\']', attrs, re.I)
|
||||||
|
val = re.search(r'value=["\']([^"\']*)["\']', attrs, re.I)
|
||||||
|
if nm:
|
||||||
|
out[nm.group(1)] = val.group(1) if val else "on"
|
||||||
|
return out
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_us_date(text: str) -> date | None:
|
||||||
|
text = text.strip()
|
||||||
|
m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
month, day, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||||
|
try:
|
||||||
|
return date(year, month, day)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rid_from_href(href: str) -> str | None:
|
||||||
|
if not href:
|
||||||
|
return None
|
||||||
|
qs = parse_qs(urlparse(href).query)
|
||||||
|
rid_list = qs.get("RID") or qs.get("rid")
|
||||||
|
if rid_list and rid_list[0]:
|
||||||
|
return str(rid_list[0]).strip()
|
||||||
|
m = re.search(r"RID=(\d+)", href, re.I)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cell_text(cell_html: str) -> str:
|
||||||
|
text = re.sub(r"<[^>]+>", " ", cell_html)
|
||||||
|
text = re.sub(r" ", " ", text, flags=re.I)
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
def _parse_table2(self, html: str) -> list[AbnormalRow]:
|
||||||
|
m = re.search(r'<table[^>]*id=["\']Table2["\'][^>]*>([\s\S]*?)</table>', html, re.I)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
body = m.group(1)
|
||||||
|
rows: list[AbnormalRow] = []
|
||||||
|
for tr in re.finditer(r"<tr[^>]*>([\s\S]*?)</tr>", body, re.I):
|
||||||
|
cells = re.findall(r"<t[dh][^>]*>([\s\S]*?)</t[dh]>", tr.group(1), re.I)
|
||||||
|
if len(cells) < 5:
|
||||||
|
continue
|
||||||
|
day = self._parse_us_date(self._cell_text(cells[0]))
|
||||||
|
if day is None:
|
||||||
|
continue
|
||||||
|
# Layout: 日期 | 應上班 | 應下班 | 實上班 | 實下班 | [異常上班] | [異常下班]
|
||||||
|
exp_in = self._cell_text(cells[1]) if len(cells) > 1 else ""
|
||||||
|
exp_out = self._cell_text(cells[2]) if len(cells) > 2 else ""
|
||||||
|
actual_in = self._cell_text(cells[3]) if len(cells) > 3 else ""
|
||||||
|
actual_out = self._cell_text(cells[4]) if len(cells) > 4 else ""
|
||||||
|
abn_in_html = cells[5] if len(cells) > 5 else ""
|
||||||
|
abn_out_html = cells[6] if len(cells) > 6 else ""
|
||||||
|
|
||||||
|
rid: str | None = None
|
||||||
|
status = "ok"
|
||||||
|
href_m = re.search(r'href=["\']([^"\']+)["\']', abn_out_html, re.I)
|
||||||
|
if href_m:
|
||||||
|
rid = self._rid_from_href(href_m.group(1))
|
||||||
|
if "應刷未刷" in abn_out_html or rid:
|
||||||
|
status = "missing_out"
|
||||||
|
elif "應刷未刷" in abn_in_html:
|
||||||
|
status = "missing_in"
|
||||||
|
elif not actual_in and not actual_out:
|
||||||
|
status = "other"
|
||||||
|
|
||||||
|
if status == "ok":
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
AbnormalRow(
|
||||||
|
date=day,
|
||||||
|
rid=rid,
|
||||||
|
exp_in=exp_in,
|
||||||
|
exp_out=exp_out,
|
||||||
|
actual_in=actual_in,
|
||||||
|
actual_out=actual_out,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def fetch_abnormal_rows(self, year: int, month: int) -> list[AbnormalRow]:
|
||||||
|
async with self._make_client() as client:
|
||||||
|
home = await client.get(f"{self.list_url}/")
|
||||||
|
if home.status_code in {401, 403}:
|
||||||
|
raise ValueError("CompBase NTLM login failed (401/403). Check username/password.")
|
||||||
|
home.raise_for_status()
|
||||||
|
html = home.text
|
||||||
|
|
||||||
|
data: dict[str, str] = {}
|
||||||
|
data.update(self._extract_hidden(html))
|
||||||
|
data.update(self._extract_selected_selects(html))
|
||||||
|
data.update(self._extract_checked_radios(html))
|
||||||
|
data["ddlYear"] = str(year)
|
||||||
|
data["ddlMonth"] = str(month)
|
||||||
|
if "subYear" in data:
|
||||||
|
data["subYear"] = str(year)
|
||||||
|
if "subMonth" in data:
|
||||||
|
data["subMonth"] = str(month)
|
||||||
|
# Image button click — do not send btnSet
|
||||||
|
data.pop("btnSet", None)
|
||||||
|
data.pop("Button1", None)
|
||||||
|
data["ImageCalendar.x"] = "10"
|
||||||
|
data["ImageCalendar.y"] = "10"
|
||||||
|
|
||||||
|
resp = await client.post(f"{self.list_url}/", data=data)
|
||||||
|
if resp.status_code in {401, 403}:
|
||||||
|
raise ValueError("CompBase NTLM login failed on calendar post.")
|
||||||
|
resp.raise_for_status()
|
||||||
|
rows = self._parse_table2(resp.text)
|
||||||
|
logger.info(
|
||||||
|
"CompBase calendar %04d-%02d: %d abnormal row(s)",
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
len(rows),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def list_missing(self, days: int = 14) -> dict[str, Any]:
|
||||||
|
if days < 1:
|
||||||
|
raise ValueError("days must be >= 1")
|
||||||
|
today = date.today()
|
||||||
|
start = today - timedelta(days=days - 1)
|
||||||
|
months: list[tuple[int, int]] = []
|
||||||
|
cursor = date(start.year, start.month, 1)
|
||||||
|
end_month = date(today.year, today.month, 1)
|
||||||
|
while cursor <= end_month:
|
||||||
|
months.append((cursor.year, cursor.month))
|
||||||
|
if cursor.month == 12:
|
||||||
|
cursor = date(cursor.year + 1, 1, 1)
|
||||||
|
else:
|
||||||
|
cursor = date(cursor.year, cursor.month + 1, 1)
|
||||||
|
|
||||||
|
all_rows: list[AbnormalRow] = []
|
||||||
|
seen: set[tuple[date, str | None]] = set()
|
||||||
|
for year, month in months:
|
||||||
|
for row in await self.fetch_abnormal_rows(year, month):
|
||||||
|
key = (row.date, row.rid)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
all_rows.append(row)
|
||||||
|
|
||||||
|
in_range = [r for r in all_rows if start <= r.date <= today]
|
||||||
|
fillable = [r for r in in_range if r.rid and r.status == "missing_out"]
|
||||||
|
skipped = [r for r in in_range if not (r.rid and r.status == "missing_out")]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"start": start.isoformat(),
|
||||||
|
"end": today.isoformat(),
|
||||||
|
"days": days,
|
||||||
|
"fillable": [r.to_dict() for r in sorted(fillable, key=lambda x: x.date)],
|
||||||
|
"skipped": [r.to_dict() for r in sorted(skipped, key=lambda x: x.date)],
|
||||||
|
"fillable_count": len(fillable),
|
||||||
|
"skipped_count": len(skipped),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _span_text(html: str, element_id: str) -> str:
|
||||||
|
m = re.search(
|
||||||
|
rf'<span[^>]+id=["\']{re.escape(element_id)}["\'][^>]*>([\s\S]*?)</span>',
|
||||||
|
html,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", m.group(1))).strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _select_options(html: str, select_name: str) -> list[str]:
|
||||||
|
m = re.search(
|
||||||
|
rf'<select[^>]*name=["\']{re.escape(select_name)}["\'][^>]*>([\s\S]*?)</select>',
|
||||||
|
html,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
opts: list[str] = []
|
||||||
|
for om in re.finditer(r"<option[^>]*>([\s\S]*?)</option>", m.group(1), re.I):
|
||||||
|
val_m = re.search(r'value=["\']([^"\']*)["\']', om.group(0), re.I)
|
||||||
|
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", om.group(1))).strip()
|
||||||
|
opts.append(val_m.group(1) if val_m else text)
|
||||||
|
return opts
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _input_value(html: str, name: str) -> str:
|
||||||
|
m = re.search(
|
||||||
|
rf'<input[^>]+name=["\']{re.escape(name)}["\'][^>]*>',
|
||||||
|
html,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if not m:
|
||||||
|
return ""
|
||||||
|
val = re.search(r'value=["\']([^"\']*)["\']', m.group(0), re.I)
|
||||||
|
return val.group(1) if val else ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_clock(value: str) -> str:
|
||||||
|
"""Normalize '5:53:00 PM' / '5:53 PM' → '5:53PM' for comparison."""
|
||||||
|
text = re.sub(r"\s+", " ", (value or "").strip()).upper()
|
||||||
|
# drop seconds if present: H:MM:SS AM/PM → H:MM AM/PM
|
||||||
|
text = re.sub(
|
||||||
|
r"\b(\d{1,2}:\d{2}):\d{2}\s*(AM|PM)\b",
|
||||||
|
r"\1 \2",
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
return re.sub(r"[^0-9APM:]", "", text)
|
||||||
|
|
||||||
|
def _choose_out_time(
|
||||||
|
self,
|
||||||
|
options: list[str],
|
||||||
|
exp_out: str,
|
||||||
|
*,
|
||||||
|
out_time_mode: str,
|
||||||
|
) -> str:
|
||||||
|
usable = [
|
||||||
|
o
|
||||||
|
for o in options
|
||||||
|
if o and o not in {"------", "自行輸入"} and "自行" not in o
|
||||||
|
]
|
||||||
|
if not usable:
|
||||||
|
raise ValueError(f"No usable out-time options: {options}")
|
||||||
|
|
||||||
|
if out_time_mode == "latest_option":
|
||||||
|
return usable[-1]
|
||||||
|
|
||||||
|
exp_key = self._normalize_clock(exp_out)
|
||||||
|
for opt in usable:
|
||||||
|
if self._normalize_clock(opt) == exp_key and exp_key:
|
||||||
|
return opt
|
||||||
|
return usable[0]
|
||||||
|
|
||||||
|
async def fill_rid(
|
||||||
|
self,
|
||||||
|
rid: str,
|
||||||
|
*,
|
||||||
|
out_time_mode: str = "expected",
|
||||||
|
dry_run: bool = False,
|
||||||
|
row_date: date | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rid = str(rid).strip()
|
||||||
|
if not rid:
|
||||||
|
raise ValueError("RID is required")
|
||||||
|
|
||||||
|
async with self._make_client() as client:
|
||||||
|
url = f"{self.fill_url}/?RID={rid}"
|
||||||
|
page = await client.get(url)
|
||||||
|
if page.status_code in {401, 403}:
|
||||||
|
raise ValueError("CompBase fill page NTLM login failed.")
|
||||||
|
page.raise_for_status()
|
||||||
|
html = page.text
|
||||||
|
|
||||||
|
exp_out = self._span_text(html, "ExpOut")
|
||||||
|
in_time = self._input_value(html, "InTime") or self._span_text(html, "ExpIn")
|
||||||
|
options = self._select_options(html, "preSetOutTimeList")
|
||||||
|
chosen = self._choose_out_time(options, exp_out, out_time_mode=out_time_mode)
|
||||||
|
label_date = self._span_text(html, "Label3") or (row_date.isoformat() if row_date else "")
|
||||||
|
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"rid": rid,
|
||||||
|
"date": label_date,
|
||||||
|
"exp_out": exp_out,
|
||||||
|
"in_time": in_time,
|
||||||
|
"chosen_out_time": chosen,
|
||||||
|
"options": options,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
result["ok"] = True
|
||||||
|
result["message"] = "dry_run"
|
||||||
|
return result
|
||||||
|
|
||||||
|
data: dict[str, str] = {}
|
||||||
|
data.update(self._extract_hidden(html))
|
||||||
|
data["RID"] = self._input_value(html, "RID") or rid
|
||||||
|
data["InTime"] = in_time
|
||||||
|
data["preSetOutTimeList"] = chosen
|
||||||
|
data["Button1"] = "送出"
|
||||||
|
|
||||||
|
resp = await client.post(url, data=data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
msg = self._span_text(resp.text, "Msg")
|
||||||
|
# re-parse msg with colors if empty
|
||||||
|
if not msg:
|
||||||
|
mm = re.search(
|
||||||
|
r'id=["\']Msg["\'][^>]*>([\s\S]*?)</span>',
|
||||||
|
resp.text,
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
if mm:
|
||||||
|
msg = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", mm.group(1))).strip()
|
||||||
|
|
||||||
|
lower = msg.lower()
|
||||||
|
failed = any(
|
||||||
|
token in msg
|
||||||
|
for token in ("失敗", "錯誤", "error", "invalid", "無法", "拒絕")
|
||||||
|
)
|
||||||
|
result["message"] = msg or "submitted"
|
||||||
|
result["ok"] = not failed
|
||||||
|
if failed:
|
||||||
|
result["error"] = msg
|
||||||
|
logger.info(
|
||||||
|
"CompBase fill RID=%s date=%s out=%s ok=%s msg=%s",
|
||||||
|
rid,
|
||||||
|
label_date,
|
||||||
|
chosen,
|
||||||
|
result["ok"],
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def fill_recent(
|
||||||
|
self,
|
||||||
|
days: int = 14,
|
||||||
|
*,
|
||||||
|
dry_run: bool = False,
|
||||||
|
out_time_mode: str = "expected",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
scan = await self.list_missing(days)
|
||||||
|
filled: list[dict[str, Any]] = []
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
skipped_rows = list(scan.get("skipped") or [])
|
||||||
|
|
||||||
|
for item in scan.get("fillable") or []:
|
||||||
|
rid = item.get("rid")
|
||||||
|
if not rid:
|
||||||
|
skipped_rows.append({**item, "reason": "no_rid"})
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
day = date.fromisoformat(item["date"]) if item.get("date") else None
|
||||||
|
result = await self.fill_rid(
|
||||||
|
str(rid),
|
||||||
|
out_time_mode=out_time_mode,
|
||||||
|
dry_run=dry_run,
|
||||||
|
row_date=day,
|
||||||
|
)
|
||||||
|
if result.get("ok"):
|
||||||
|
filled.append({**item, **result})
|
||||||
|
else:
|
||||||
|
errors.append({**item, **result})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.exception("CompBase fill failed for RID=%s", rid)
|
||||||
|
errors.append({**item, "ok": False, "error": str(exc)})
|
||||||
|
|
||||||
|
# annotate skipped without rid
|
||||||
|
for row in skipped_rows:
|
||||||
|
row.setdefault("reason", "no_rid" if not row.get("rid") else row.get("status", "skipped"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": len(errors) == 0,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"days": days,
|
||||||
|
"out_time_mode": out_time_mode,
|
||||||
|
"start": scan.get("start"),
|
||||||
|
"end": scan.get("end"),
|
||||||
|
"fillable_count": scan.get("fillable_count"),
|
||||||
|
"filled": filled,
|
||||||
|
"skipped": skipped_rows,
|
||||||
|
"errors": errors,
|
||||||
|
"filled_count": len(filled),
|
||||||
|
"skipped_count": len(skipped_rows),
|
||||||
|
"error_count": len(errors),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def status(self) -> dict[str, Any]:
|
||||||
|
username = (getattr(settings, "compbase_username", "") or "").strip()
|
||||||
|
source = "compbase"
|
||||||
|
if not username:
|
||||||
|
username = (getattr(settings, "pts_username", "") or "").strip()
|
||||||
|
source = "pts" if username else "none"
|
||||||
|
has_password = bool(
|
||||||
|
(getattr(settings, "compbase_password", "") or "")
|
||||||
|
or (getattr(settings, "pts_password", "") or "")
|
||||||
|
)
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"list_url": self.list_url,
|
||||||
|
"fill_url": self.fill_url,
|
||||||
|
"username": username or None,
|
||||||
|
"credential_source": source,
|
||||||
|
"has_credentials": bool(username and has_password),
|
||||||
|
"default_days": int(getattr(settings, "compbase_default_days", 14) or 14),
|
||||||
|
"out_time_mode": getattr(settings, "compbase_out_time_mode", "expected") or "expected",
|
||||||
|
"connected": False,
|
||||||
|
}
|
||||||
|
if not result["has_credentials"]:
|
||||||
|
result["error"] = "No credentials configured"
|
||||||
|
return result
|
||||||
|
try:
|
||||||
|
async with self._make_client() as client:
|
||||||
|
resp = await client.get(f"{self.list_url}/")
|
||||||
|
result["connected"] = resp.status_code == 200
|
||||||
|
result["http_status"] = resp.status_code
|
||||||
|
if resp.status_code == 200:
|
||||||
|
# pull employee name if present
|
||||||
|
m = re.search(r'id=["\']CName["\'][^>]*>([^<]+)', resp.text)
|
||||||
|
if m:
|
||||||
|
result["display_name"] = m.group(1).strip()
|
||||||
|
m = re.search(r'id=["\']EmpID["\'][^>]*>([^<]+)', resp.text)
|
||||||
|
if m:
|
||||||
|
result["emp_id"] = m.group(1).strip()
|
||||||
|
else:
|
||||||
|
result["error"] = f"HTTP {resp.status_code}"
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
result["error"] = str(exc)
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dates_text(text: str) -> list[date]:
|
||||||
|
dates: list[date] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
for token in re.split(r"[\s,;]+", line):
|
||||||
|
token = token.strip()
|
||||||
|
if token:
|
||||||
|
dates.append(date.fromisoformat(token))
|
||||||
|
return sorted(set(dates))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_dates_file(path: str | Path) -> list[date]:
|
||||||
|
content = Path(path).read_text(encoding="utf-8")
|
||||||
|
dates = parse_dates_text(content)
|
||||||
|
if not dates:
|
||||||
|
raise ValueError(f"No dates found in {path}")
|
||||||
|
return dates
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from services.gitlab_client import GitLabTask
|
||||||
|
|
||||||
|
|
||||||
|
def format_professional_description(task: GitLabTask) -> str:
|
||||||
|
if task.source == "board_bug":
|
||||||
|
lead = "Investigated and resolved defect"
|
||||||
|
elif task.source == "milestone":
|
||||||
|
lead = "Delivered milestone work item"
|
||||||
|
else:
|
||||||
|
lead = "Completed engineering task"
|
||||||
|
|
||||||
|
return f"{lead} ({task.reference}): {task.title}"
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.gitlab_client import GitLabTask
|
||||||
|
from services.holidays_util import iter_workdays
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DayEntry:
|
||||||
|
date: str
|
||||||
|
description: str
|
||||||
|
take_hours: float
|
||||||
|
gitlab_task_id: int | None = None
|
||||||
|
pts_task_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _to_units(hours: float, step: float) -> int:
|
||||||
|
return int(round(hours / step))
|
||||||
|
|
||||||
|
|
||||||
|
def _from_units(units: int, step: float) -> float:
|
||||||
|
return round(units * step, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_hours(num_tasks: int, max_hours: float, step: float) -> list[float]:
|
||||||
|
if num_tasks <= 0:
|
||||||
|
return []
|
||||||
|
if num_tasks == 1:
|
||||||
|
return [max_hours]
|
||||||
|
|
||||||
|
max_units = _to_units(max_hours, step)
|
||||||
|
min_units = 1
|
||||||
|
if max_units < num_tasks * min_units:
|
||||||
|
return [_from_units(min_units, step)] * num_tasks
|
||||||
|
|
||||||
|
remaining = max_units - num_tasks * min_units
|
||||||
|
weights = [random.random() for _ in range(num_tasks)]
|
||||||
|
total_weight = sum(weights) or 1
|
||||||
|
extra = [int(remaining * w / total_weight) for w in weights]
|
||||||
|
leftover = remaining - sum(extra)
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
while leftover > 0:
|
||||||
|
extra[idx % num_tasks] += 1
|
||||||
|
leftover -= 1
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
return [_from_units(min_units + extra[i], step) for i in range(num_tasks)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily_plan(
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
workdays: list[date],
|
||||||
|
*,
|
||||||
|
max_hours: float | None = None,
|
||||||
|
hour_step: float | None = None,
|
||||||
|
tasks_per_day: int | None = None,
|
||||||
|
) -> dict[str, list[DayEntry]]:
|
||||||
|
max_hours = max_hours or settings.max_hours_per_day
|
||||||
|
hour_step = hour_step or settings.hour_step
|
||||||
|
|
||||||
|
if not workdays:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
return {d.isoformat(): [] for d in workdays}
|
||||||
|
|
||||||
|
per_day = tasks_per_day or min(3, max(1, len(tasks)))
|
||||||
|
plan: dict[str, list[DayEntry]] = {}
|
||||||
|
task_index = 0
|
||||||
|
|
||||||
|
for workday in workdays:
|
||||||
|
day_key = workday.isoformat()
|
||||||
|
day_tasks: list[GitLabTask] = []
|
||||||
|
for _ in range(per_day):
|
||||||
|
day_tasks.append(tasks[task_index % len(tasks)])
|
||||||
|
task_index += 1
|
||||||
|
|
||||||
|
hours = distribute_hours(len(day_tasks), max_hours, hour_step)
|
||||||
|
plan[day_key] = [
|
||||||
|
DayEntry(
|
||||||
|
date=day_key,
|
||||||
|
description=task.to_description(),
|
||||||
|
take_hours=hours[i],
|
||||||
|
gitlab_task_id=task.id,
|
||||||
|
)
|
||||||
|
for i, task in enumerate(day_tasks)
|
||||||
|
]
|
||||||
|
|
||||||
|
return plan
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan_for_dates(
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
) -> dict[str, list[DayEntry]]:
|
||||||
|
workdays = iter_workdays(start, end)
|
||||||
|
return build_daily_plan(tasks, workdays)
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan_for_day_list(
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
days: list[date],
|
||||||
|
) -> dict[str, list[DayEntry]]:
|
||||||
|
sorted_days = sorted(set(days))
|
||||||
|
return build_daily_plan(tasks, sorted_days)
|
||||||
|
|
||||||
|
|
||||||
|
def entries_from_last_state(
|
||||||
|
last_entries: list[dict],
|
||||||
|
workdays: list[date],
|
||||||
|
) -> dict[str, list[DayEntry]]:
|
||||||
|
if not last_entries or not workdays:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
templates = [
|
||||||
|
DayEntry(
|
||||||
|
date="",
|
||||||
|
description=e.get("description", ""),
|
||||||
|
take_hours=float(e.get("take_hours", 2)),
|
||||||
|
gitlab_task_id=e.get("gitlab_task_id"),
|
||||||
|
)
|
||||||
|
for e in last_entries
|
||||||
|
if e.get("description")
|
||||||
|
]
|
||||||
|
if not templates:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
plan: dict[str, list[DayEntry]] = {}
|
||||||
|
for workday in workdays:
|
||||||
|
day_key = workday.isoformat()
|
||||||
|
hours = distribute_hours(len(templates), settings.max_hours_per_day, settings.hour_step)
|
||||||
|
plan[day_key] = [
|
||||||
|
DayEntry(
|
||||||
|
date=day_key,
|
||||||
|
description=templates[i].description,
|
||||||
|
take_hours=hours[i],
|
||||||
|
gitlab_task_id=templates[i].gitlab_task_id,
|
||||||
|
)
|
||||||
|
for i in range(len(templates))
|
||||||
|
]
|
||||||
|
return plan
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from paths import EXTENSION_DIR
|
||||||
|
|
||||||
|
ZIP_FOLDER_NAME = "pts-session-sync"
|
||||||
|
|
||||||
|
|
||||||
|
def build_extension_zip() -> bytes:
|
||||||
|
if not EXTENSION_DIR.is_dir():
|
||||||
|
raise FileNotFoundError(f"Extension directory not found: {EXTENSION_DIR}")
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
for path in sorted(EXTENSION_DIR.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
if path.name.startswith("."):
|
||||||
|
continue
|
||||||
|
arcname = Path(ZIP_FOLDER_NAME) / path.relative_to(EXTENSION_DIR)
|
||||||
|
archive.write(path, arcname.as_posix())
|
||||||
|
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.distributor import DayEntry
|
||||||
|
from services.gitlab_client import GitLabClient
|
||||||
|
from services.holidays_util import is_workday
|
||||||
|
from services.planner import Planner
|
||||||
|
from services.pts_client import PTSClient
|
||||||
|
from services.state_store import (
|
||||||
|
get_last_entries,
|
||||||
|
get_pts_session,
|
||||||
|
load_state,
|
||||||
|
mark_dates_filled,
|
||||||
|
set_last_entries,
|
||||||
|
)
|
||||||
|
from services.teams_calendar_scraper import TeamsCalendarScraper
|
||||||
|
|
||||||
|
|
||||||
|
class FillService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.gitlab = GitLabClient()
|
||||||
|
self.pts = PTSClient()
|
||||||
|
self.planner = Planner()
|
||||||
|
|
||||||
|
async def fetch_gitlab_tasks(self) -> list[dict[str, Any]]:
|
||||||
|
tasks = await self.gitlab.get_assigned_tasks()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"iid": t.iid,
|
||||||
|
"title": t.title,
|
||||||
|
"reference": t.reference,
|
||||||
|
"web_url": t.web_url,
|
||||||
|
"source": t.source,
|
||||||
|
"labels": t.labels or [],
|
||||||
|
"description": t.to_description(),
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
|
||||||
|
async def preview_plan(
|
||||||
|
self,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
*,
|
||||||
|
use_last_if_empty: bool = True,
|
||||||
|
use_llm: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await self.planner.build_plan(
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
use_llm=use_llm,
|
||||||
|
use_last_if_empty=use_last_if_empty,
|
||||||
|
)
|
||||||
|
payload = {k: v for k, v in result.items() if k != "_plan_entries"}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
async def preview_plan_for_days(
|
||||||
|
self,
|
||||||
|
days: list[date],
|
||||||
|
*,
|
||||||
|
use_last_if_empty: bool = True,
|
||||||
|
use_llm: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await self.planner.build_plan_for_days(
|
||||||
|
days,
|
||||||
|
use_llm=use_llm,
|
||||||
|
use_last_if_empty=use_last_if_empty,
|
||||||
|
)
|
||||||
|
return {k: v for k, v in result.items() if k != "_plan_entries"}
|
||||||
|
|
||||||
|
async def fill_dates(
|
||||||
|
self,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
*,
|
||||||
|
skip_existing: bool = True,
|
||||||
|
dry_run: bool = False,
|
||||||
|
use_llm: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
planned = await self.planner.build_plan(start, end, use_llm=use_llm)
|
||||||
|
return await self._execute_plan(
|
||||||
|
planned,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fill_date_list(
|
||||||
|
self,
|
||||||
|
days: list[date],
|
||||||
|
*,
|
||||||
|
skip_existing: bool = True,
|
||||||
|
dry_run: bool = False,
|
||||||
|
use_llm: bool | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
planned = await self.planner.build_plan_for_days(days, use_llm=use_llm)
|
||||||
|
result = await self._execute_plan(
|
||||||
|
planned,
|
||||||
|
skip_existing=skip_existing,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
result["requested_dates"] = [d.isoformat() for d in sorted(set(days))]
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _execute_plan(
|
||||||
|
self,
|
||||||
|
planned: dict[str, Any],
|
||||||
|
*,
|
||||||
|
skip_existing: bool,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
plan: dict[str, list[DayEntry]] = planned["_plan_entries"]
|
||||||
|
source = planned["source"]
|
||||||
|
|
||||||
|
if not any(plan.values()):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"message": "No GitLab tasks and no previous entries to reuse.",
|
||||||
|
"source": source,
|
||||||
|
"analysis": planned.get("analysis", {}),
|
||||||
|
"created": [],
|
||||||
|
"skipped": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
project_code = await self.pts.resolve_project_code()
|
||||||
|
default_task_id = await self.pts.resolve_task_id()
|
||||||
|
task_id_cache: dict[str, int] = {}
|
||||||
|
|
||||||
|
created: list[dict[str, Any]] = []
|
||||||
|
skipped: list[dict[str, Any]] = []
|
||||||
|
errors: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for day, entries in sorted(plan.items()):
|
||||||
|
if not entries:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if skip_existing:
|
||||||
|
existing = await self.pts.search_reports(day, day, project_code=project_code)
|
||||||
|
existing_hours = sum(float(r.get("takeHours") or 0) for r in existing)
|
||||||
|
if existing_hours >= settings.max_hours_per_day - 0.01:
|
||||||
|
skipped.append({"date": day, "reason": "already_filled", "hours": existing_hours})
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
task_name = entry.pts_task_name or settings.pts_default_task_name
|
||||||
|
if task_name not in task_id_cache:
|
||||||
|
task_id_cache[task_name] = await self.pts.resolve_task_id(task_name)
|
||||||
|
task_id = task_id_cache[task_name]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"projectCode": project_code,
|
||||||
|
"taskId": task_id,
|
||||||
|
"description": entry.description,
|
||||||
|
"date": day.isoformat() if hasattr(day, "isoformat") else str(day),
|
||||||
|
"takeHours": entry.take_hours,
|
||||||
|
"licenseIds": [],
|
||||||
|
"pts_task_name": task_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
created.append({"date": day, **payload, "dry_run": True})
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
report_id = await self.pts.create_report(payload)
|
||||||
|
created.append({"date": day, "id": report_id, **payload})
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append({"date": day, "description": entry.description, "error": str(exc)})
|
||||||
|
|
||||||
|
if created and not dry_run:
|
||||||
|
mark_dates_filled(list({c["date"] for c in created}))
|
||||||
|
set_last_entries(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"description": e.description,
|
||||||
|
"take_hours": e.take_hours,
|
||||||
|
"gitlab_task_id": e.gitlab_task_id,
|
||||||
|
}
|
||||||
|
for entries in plan.values()
|
||||||
|
for e in entries
|
||||||
|
][:10]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include human-readable plan for the web UI (same shape as preview)
|
||||||
|
plan_public: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for day, entries in plan.items():
|
||||||
|
day_key = day.isoformat() if hasattr(day, "isoformat") else str(day)
|
||||||
|
plan_public[day_key] = [
|
||||||
|
{
|
||||||
|
"description": e.description,
|
||||||
|
"take_hours": e.take_hours,
|
||||||
|
"pts_task_name": e.pts_task_name,
|
||||||
|
"gitlab_task_id": e.gitlab_task_id,
|
||||||
|
}
|
||||||
|
for e in entries
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": len(errors) == 0,
|
||||||
|
"source": source,
|
||||||
|
"analysis": planned.get("analysis", {}),
|
||||||
|
"project_code": project_code,
|
||||||
|
"task_id": default_task_id,
|
||||||
|
"created": created,
|
||||||
|
"skipped": skipped,
|
||||||
|
"errors": errors,
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"workdays": planned.get("workdays", list(plan_public.keys())),
|
||||||
|
"gitlab_task_count": planned.get("gitlab_task_count"),
|
||||||
|
"plan": plan_public,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fill_today(self, *, dry_run: bool = False, use_llm: bool | None = None) -> dict[str, Any]:
|
||||||
|
today = date.today()
|
||||||
|
if not is_workday(today):
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"{today.isoformat()} is not a workday (weekend/holiday). Skipped.",
|
||||||
|
"created": [],
|
||||||
|
"skipped": [{"date": today.isoformat(), "reason": "holiday_or_weekend"}],
|
||||||
|
}
|
||||||
|
return await self.fill_dates(today, today, dry_run=dry_run, use_llm=use_llm)
|
||||||
|
|
||||||
|
async def get_status(self) -> dict[str, Any]:
|
||||||
|
state = load_state()
|
||||||
|
session = get_pts_session()
|
||||||
|
has_session = bool(session and session.get("accessToken"))
|
||||||
|
|
||||||
|
status: dict[str, Any] = {
|
||||||
|
"has_pts_session": has_session,
|
||||||
|
"pts_session_updated_at": state.get("pts_session_updated_at"),
|
||||||
|
"last_entries_count": len(get_last_entries()),
|
||||||
|
"gitlab_configured": bool(settings.gitlab_token),
|
||||||
|
"llm_configured": bool(settings.xai_api_key),
|
||||||
|
"use_llm": settings.use_llm,
|
||||||
|
"use_description_variation": settings.use_description_variation,
|
||||||
|
"grok_model": settings.grok_model,
|
||||||
|
"auto_fill_enabled": settings.auto_fill_enabled,
|
||||||
|
"auto_fill_time": f"{settings.auto_fill_hour:02d}:{settings.auto_fill_minute:02d}",
|
||||||
|
"teams_calendar_enabled": settings.teams_calendar_enabled,
|
||||||
|
"pts_username": settings.pts_username or None,
|
||||||
|
"teams_calendar": TeamsCalendarScraper().status(),
|
||||||
|
# backward-compatible alias for old UI fields
|
||||||
|
"graph_calendar": TeamsCalendarScraper().status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_session:
|
||||||
|
try:
|
||||||
|
pts_info = await self.pts.verify_connection()
|
||||||
|
status.update(pts_info)
|
||||||
|
status["pts_projects"] = pts_info["project_count"]
|
||||||
|
status["pts_task_types"] = pts_info["task_type_count"]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
status["pts_error"] = str(exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
gitlab_tasks = await self.fetch_gitlab_tasks()
|
||||||
|
status["gitlab_open_tasks"] = len(gitlab_tasks)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
status["gitlab_error"] = str(exc)
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
def ensure_ready(self) -> None:
|
||||||
|
if not settings.gitlab_token:
|
||||||
|
raise ValueError("Missing GITLAB_TOKEN in .env")
|
||||||
|
session = get_pts_session()
|
||||||
|
if not session or not session.get("accessToken"):
|
||||||
|
raise ValueError(
|
||||||
|
"Missing PTS session. Login with PTS account/password on the web UI (or sync via Chrome extension as fallback)."
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitLabTask:
|
||||||
|
id: int
|
||||||
|
iid: int
|
||||||
|
title: str
|
||||||
|
reference: str
|
||||||
|
web_url: str
|
||||||
|
project_path: str
|
||||||
|
source: str = "unknown"
|
||||||
|
labels: list[str] | None = None
|
||||||
|
|
||||||
|
def to_description(self) -> str:
|
||||||
|
from services.description_formatter import format_professional_description
|
||||||
|
|
||||||
|
return format_professional_description(self)
|
||||||
|
|
||||||
|
|
||||||
|
class GitLabClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url = settings.gitlab_url.rstrip("/")
|
||||||
|
self.token = (settings.gitlab_token or "").strip().strip("\"'")
|
||||||
|
self._username: Optional[str] = None
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {"PRIVATE-TOKEN": self.token}
|
||||||
|
|
||||||
|
def _auth_error_message(self, response: httpx.Response) -> str:
|
||||||
|
detail = ""
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
detail = (
|
||||||
|
payload.get("error_description")
|
||||||
|
or payload.get("message")
|
||||||
|
or payload.get("error")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
detail = (response.text or "")[:200]
|
||||||
|
detail = str(detail).strip()
|
||||||
|
lower = detail.lower()
|
||||||
|
if response.status_code == 401:
|
||||||
|
if "revok" in lower:
|
||||||
|
return (
|
||||||
|
"GitLab token 已撤銷或失效。請到 GitLab → Preferences → Access Tokens "
|
||||||
|
"重新建立 Personal Access Token(勾選 api),再到設定頁貼上新 token。"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"GitLab token 無效(401)。請確認已貼上新的 Personal Access Token,"
|
||||||
|
f"且 URL 為 {self.base_url}。{(' 詳情: ' + detail) if detail else ''}"
|
||||||
|
)
|
||||||
|
if response.status_code == 403:
|
||||||
|
return f"GitLab token 權限不足(403)。請勾選 api scope。{(' 詳情: ' + detail) if detail else ''}"
|
||||||
|
return f"GitLab API error {response.status_code}: {detail or response.reason_phrase}"
|
||||||
|
|
||||||
|
async def _get_current_username(self) -> str:
|
||||||
|
if self._username:
|
||||||
|
return self._username
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/api/v4/user")
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise ValueError(self._auth_error_message(response))
|
||||||
|
response.raise_for_status()
|
||||||
|
self._username = response.json()["username"]
|
||||||
|
return self._username
|
||||||
|
|
||||||
|
def _issue_to_task(self, issue: dict[str, Any], source: str) -> GitLabTask:
|
||||||
|
refs = issue.get("references") or {}
|
||||||
|
return GitLabTask(
|
||||||
|
id=issue["id"],
|
||||||
|
iid=issue["iid"],
|
||||||
|
title=issue["title"],
|
||||||
|
reference=refs.get("full") or f"#{issue['iid']}",
|
||||||
|
web_url=issue.get("web_url", ""),
|
||||||
|
project_path=refs.get("relative", ""),
|
||||||
|
source=source,
|
||||||
|
labels=issue.get("labels") or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fetch_project_issues(
|
||||||
|
self,
|
||||||
|
project_path: str,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
extra_params: Optional[dict[str, Any]] = None,
|
||||||
|
) -> list[GitLabTask]:
|
||||||
|
encoded = quote(project_path, safe="")
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"state": "opened",
|
||||||
|
"per_page": 100,
|
||||||
|
"order_by": "updated_at",
|
||||||
|
"sort": "desc",
|
||||||
|
}
|
||||||
|
if extra_params:
|
||||||
|
params.update(extra_params)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{self.base_url}/api/v4/projects/{encoded}/issues",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
issues = response.json()
|
||||||
|
|
||||||
|
return [self._issue_to_task(issue, source) for issue in issues]
|
||||||
|
|
||||||
|
async def get_board_bug_tasks(self) -> list[GitLabTask]:
|
||||||
|
username = await self._get_current_username()
|
||||||
|
labels = ",".join(settings.gitlab_board_label_list)
|
||||||
|
return await self._fetch_project_issues(
|
||||||
|
settings.gitlab_project_path,
|
||||||
|
source="board_bug",
|
||||||
|
extra_params={
|
||||||
|
"assignee_username": username,
|
||||||
|
"labels": labels,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _pick_current_milestone(
|
||||||
|
self,
|
||||||
|
active_milestones: list[dict[str, Any]],
|
||||||
|
closed_milestones: list[dict[str, Any]],
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
if settings.gitlab_milestone_title:
|
||||||
|
for milestone in active_milestones:
|
||||||
|
if milestone.get("title") == settings.gitlab_milestone_title:
|
||||||
|
return milestone
|
||||||
|
return {"title": settings.gitlab_milestone_title, "state": "configured"}
|
||||||
|
|
||||||
|
version_re = re.compile(r"^SCC_v", re.IGNORECASE)
|
||||||
|
active_versions = [
|
||||||
|
milestone
|
||||||
|
for milestone in active_milestones
|
||||||
|
if milestone.get("state") == "active"
|
||||||
|
and "backlog" not in milestone.get("title", "").lower()
|
||||||
|
and version_re.match(milestone.get("title", ""))
|
||||||
|
]
|
||||||
|
if not active_versions:
|
||||||
|
return None
|
||||||
|
|
||||||
|
closed_versions = [
|
||||||
|
milestone
|
||||||
|
for milestone in closed_milestones
|
||||||
|
if version_re.match(milestone.get("title", ""))
|
||||||
|
]
|
||||||
|
latest_closed_version = 0
|
||||||
|
if closed_versions:
|
||||||
|
latest_closed_version = max(
|
||||||
|
self._milestone_version_key(milestone.get("title", ""))
|
||||||
|
for milestone in closed_versions
|
||||||
|
)
|
||||||
|
|
||||||
|
# 目前衝刺 = 最新已關閉版本之後、版本號最小的 active milestone
|
||||||
|
# 例:a10 已關閉 → 選 a11(不是規劃中的 a12)
|
||||||
|
successors = [
|
||||||
|
milestone
|
||||||
|
for milestone in active_versions
|
||||||
|
if self._milestone_version_key(milestone.get("title", "")) > latest_closed_version
|
||||||
|
]
|
||||||
|
if successors:
|
||||||
|
successors.sort(key=lambda milestone: self._milestone_version_key(milestone.get("title", "")))
|
||||||
|
return successors[0]
|
||||||
|
|
||||||
|
active_versions.sort(key=lambda milestone: self._milestone_version_key(milestone.get("title", "")))
|
||||||
|
return active_versions[0]
|
||||||
|
|
||||||
|
def _milestone_version_key(self, title: str) -> int:
|
||||||
|
match = re.search(r"a(\d+)$", title, re.IGNORECASE)
|
||||||
|
return int(match.group(1)) if match else 0
|
||||||
|
|
||||||
|
async def get_milestone_tasks(self) -> tuple[Optional[str], list[GitLabTask]]:
|
||||||
|
encoded = quote(settings.gitlab_project_path, safe="")
|
||||||
|
username = await self._get_current_username()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, headers=self._headers()) as client:
|
||||||
|
active_resp = await client.get(
|
||||||
|
f"{self.base_url}/api/v4/projects/{encoded}/milestones",
|
||||||
|
params={"state": "active", "per_page": 50},
|
||||||
|
)
|
||||||
|
active_resp.raise_for_status()
|
||||||
|
active_milestones = active_resp.json()
|
||||||
|
|
||||||
|
closed_resp = await client.get(
|
||||||
|
f"{self.base_url}/api/v4/projects/{encoded}/milestones",
|
||||||
|
params={"state": "closed", "per_page": 20, "order_by": "updated_at", "sort": "desc"},
|
||||||
|
)
|
||||||
|
closed_resp.raise_for_status()
|
||||||
|
closed_milestones = closed_resp.json()
|
||||||
|
|
||||||
|
milestone = await self._pick_current_milestone(active_milestones, closed_milestones)
|
||||||
|
if not milestone:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
title = milestone["title"]
|
||||||
|
tasks = await self._fetch_project_issues(
|
||||||
|
settings.gitlab_project_path,
|
||||||
|
source="milestone",
|
||||||
|
extra_params={
|
||||||
|
"assignee_username": username,
|
||||||
|
"milestone": title,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return title, tasks
|
||||||
|
|
||||||
|
async def get_assigned_tasks(self) -> list[GitLabTask]:
|
||||||
|
if not self.token:
|
||||||
|
raise ValueError("GITLAB_TOKEN is not configured")
|
||||||
|
|
||||||
|
board_tasks = await self.get_board_bug_tasks()
|
||||||
|
milestone_title, milestone_tasks = await self.get_milestone_tasks()
|
||||||
|
|
||||||
|
merged: dict[int, GitLabTask] = {}
|
||||||
|
for task in board_tasks:
|
||||||
|
merged[task.id] = task
|
||||||
|
for task in milestone_tasks:
|
||||||
|
merged.setdefault(task.id, task)
|
||||||
|
|
||||||
|
tasks = list(merged.values())
|
||||||
|
tasks.sort(key=lambda t: (0 if t.source == "board_bug" else 1, t.reference))
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
async def get_task_sources(self) -> dict[str, Any]:
|
||||||
|
board_tasks = await self.get_board_bug_tasks()
|
||||||
|
milestone_title, milestone_tasks = await self.get_milestone_tasks()
|
||||||
|
merged = await self.get_assigned_tasks()
|
||||||
|
return {
|
||||||
|
"board": {
|
||||||
|
"project": settings.gitlab_project_path,
|
||||||
|
"board_id": settings.gitlab_board_id,
|
||||||
|
"labels": settings.gitlab_board_label_list,
|
||||||
|
"count": len(board_tasks),
|
||||||
|
"tasks": [t.__dict__ for t in board_tasks],
|
||||||
|
},
|
||||||
|
"milestone": {
|
||||||
|
"title": milestone_title,
|
||||||
|
"count": len(milestone_tasks),
|
||||||
|
"tasks": [t.__dict__ for t in milestone_tasks],
|
||||||
|
},
|
||||||
|
"merged_count": len(merged),
|
||||||
|
"tasks": [t.__dict__ for t in merged],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,464 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.description_formatter import format_professional_description
|
||||||
|
from services.distributor import DayEntry
|
||||||
|
from services.gitlab_client import GitLabTask
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_PTS_TASK_TYPES = [
|
||||||
|
"Implement",
|
||||||
|
"Meeting",
|
||||||
|
"Test",
|
||||||
|
"Survey",
|
||||||
|
"Bug Fix",
|
||||||
|
"Support",
|
||||||
|
"Trouble Shooting",
|
||||||
|
"Document",
|
||||||
|
"Design",
|
||||||
|
"Code Review",
|
||||||
|
"Training",
|
||||||
|
"Project Management",
|
||||||
|
"Manager Task",
|
||||||
|
"POC",
|
||||||
|
"Interview",
|
||||||
|
"Investigating",
|
||||||
|
"Misc",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class GrokClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.api_key = settings.xai_api_key
|
||||||
|
self.base_url = settings.xai_base_url.rstrip("/")
|
||||||
|
self.model = settings.grok_model
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self.api_key)
|
||||||
|
|
||||||
|
async def plan_pts_entries(
|
||||||
|
self,
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
workdays: list[date],
|
||||||
|
*,
|
||||||
|
pts_project_name: str,
|
||||||
|
pts_task_name: str,
|
||||||
|
max_hours_per_day: float,
|
||||||
|
hour_step: float,
|
||||||
|
) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]:
|
||||||
|
if not self.enabled:
|
||||||
|
raise ValueError("XAI_API_KEY is not configured")
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
raise ValueError("No GitLab tasks to analyze")
|
||||||
|
if not workdays:
|
||||||
|
raise ValueError("No workdays in selected range")
|
||||||
|
|
||||||
|
prompt = self._build_prompt(
|
||||||
|
tasks,
|
||||||
|
workdays,
|
||||||
|
pts_project_name=pts_project_name,
|
||||||
|
pts_task_name=pts_task_name,
|
||||||
|
max_hours_per_day=max_hours_per_day,
|
||||||
|
hour_step=hour_step,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = await self._chat(prompt)
|
||||||
|
parsed = self._parse_json(raw)
|
||||||
|
plan = self._to_plan(parsed, tasks, workdays, hour_step, max_hours_per_day)
|
||||||
|
analysis = {
|
||||||
|
"model": self.model,
|
||||||
|
"summary": parsed.get("summary", ""),
|
||||||
|
"notes": parsed.get("notes", ""),
|
||||||
|
"days": parsed.get("days", []),
|
||||||
|
}
|
||||||
|
return plan, analysis
|
||||||
|
|
||||||
|
def _build_prompt(
|
||||||
|
self,
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
workdays: list[date],
|
||||||
|
*,
|
||||||
|
pts_project_name: str,
|
||||||
|
pts_task_name: str,
|
||||||
|
max_hours_per_day: float,
|
||||||
|
hour_step: float,
|
||||||
|
) -> str:
|
||||||
|
task_payload = [
|
||||||
|
{
|
||||||
|
"id": task.id,
|
||||||
|
"reference": task.reference,
|
||||||
|
"title": task.title,
|
||||||
|
"source": task.source,
|
||||||
|
"labels": task.labels or [],
|
||||||
|
}
|
||||||
|
for task in tasks
|
||||||
|
]
|
||||||
|
days_payload = [d.isoformat() for d in workdays]
|
||||||
|
|
||||||
|
return f"""You are a senior software engineer preparing professional PTS timesheet entries for Supermicro.
|
||||||
|
|
||||||
|
Plan daily work logs based on the GitLab tasks below.
|
||||||
|
|
||||||
|
## PTS constraints
|
||||||
|
- Project (fixed): {pts_project_name}
|
||||||
|
- Task type (fixed): {pts_task_name}
|
||||||
|
- Plan only: description + take_hours
|
||||||
|
- Max {max_hours_per_day} hours per day
|
||||||
|
- Hours must be multiples of {hour_step}
|
||||||
|
- 1 to 3 entries per day
|
||||||
|
- Prioritize board bugs (source=board_bug), then milestone items (source=milestone)
|
||||||
|
|
||||||
|
## Description style (English only — mandatory)
|
||||||
|
Write concise, professional engineering work logs suitable for management review.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- English only. No Chinese or other languages.
|
||||||
|
- Use past tense or present-perfect tense (e.g., "Investigated", "Implemented", "Refactored", "Validated").
|
||||||
|
- Start with a strong action verb.
|
||||||
|
- Include the GitLab reference in parentheses, e.g. (super-cloud/scc-flex#1319).
|
||||||
|
- Do NOT paste the raw GitLab title verbatim — rewrite as what was accomplished that day.
|
||||||
|
- One sentence, 12–25 words.
|
||||||
|
- Sound credible and specific, not generic.
|
||||||
|
- When the same gitlab_task_id appears on multiple days, each description MUST be a unique paraphrase:
|
||||||
|
vary verbs and daily focus (investigated, reproduced, patched, validated, refactored, reviewed, etc.).
|
||||||
|
- Never reuse identical description text across entries.
|
||||||
|
|
||||||
|
Good examples:
|
||||||
|
- "Investigated Job Report snapshot regression after template deletion (super-cloud/scc-flex#1319)."
|
||||||
|
- "Refactored Jobs Schedule facade error responses for API consistency (super-cloud/scc-flex#1294)."
|
||||||
|
- "Validated firmware baseline history progress calculation under multi-target runs (super-cloud/scc-flex#1296)."
|
||||||
|
|
||||||
|
Bad examples:
|
||||||
|
- "Worked on bug" (too vague)
|
||||||
|
- Copy-paste of full GitLab title without rewrite
|
||||||
|
- Any non-English text
|
||||||
|
|
||||||
|
## Workdays
|
||||||
|
{json.dumps(days_payload)}
|
||||||
|
|
||||||
|
## GitLab tasks
|
||||||
|
{json.dumps(task_payload, indent=2)}
|
||||||
|
|
||||||
|
Return JSON only:
|
||||||
|
{{
|
||||||
|
"summary": "Brief English summary of the overall plan",
|
||||||
|
"notes": "English explanation of allocation logic",
|
||||||
|
"days": [
|
||||||
|
{{
|
||||||
|
"date": "YYYY-MM-DD",
|
||||||
|
"entries": [
|
||||||
|
{{
|
||||||
|
"gitlab_task_id": 123,
|
||||||
|
"description": "Investigated Job Report snapshot regression after template deletion (super-cloud/scc-flex#1319).",
|
||||||
|
"take_hours": 2.5,
|
||||||
|
"reasoning": "English rationale for task selection and hours"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _chat(self, prompt: str, *, temperature: float = 0.2) -> str:
|
||||||
|
payload = {
|
||||||
|
"model": self.model,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"You are a professional technical writing assistant. "
|
||||||
|
"Output valid JSON only. All text fields must be in English."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"temperature": temperature,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self.base_url}/v1/chat/completions",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
if not content:
|
||||||
|
raise ValueError("Grok returned empty content")
|
||||||
|
return content
|
||||||
|
|
||||||
|
def _parse_json(self, text: str) -> dict[str, Any]:
|
||||||
|
cleaned = text.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||||||
|
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Grok response is not valid JSON") from None
|
||||||
|
return json.loads(match.group())
|
||||||
|
|
||||||
|
def _to_plan(
|
||||||
|
self,
|
||||||
|
parsed: dict[str, Any],
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
workdays: list[date],
|
||||||
|
hour_step: float,
|
||||||
|
max_hours_per_day: float,
|
||||||
|
) -> dict[str, list[DayEntry]]:
|
||||||
|
valid_ids = {task.id for task in tasks}
|
||||||
|
task_by_id = {task.id: task for task in tasks}
|
||||||
|
allowed_days = {d.isoformat() for d in workdays}
|
||||||
|
plan: dict[str, list[DayEntry]] = {d.isoformat(): [] for d in workdays}
|
||||||
|
|
||||||
|
for day_block in parsed.get("days", []):
|
||||||
|
day = str(day_block.get("date", ""))
|
||||||
|
if day not in allowed_days:
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries: list[DayEntry] = []
|
||||||
|
total_hours = 0.0
|
||||||
|
for item in day_block.get("entries", []):
|
||||||
|
task_id = item.get("gitlab_task_id")
|
||||||
|
if task_id not in valid_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
hours = float(item.get("take_hours", 0))
|
||||||
|
hours = round(round(hours / hour_step) * hour_step, 1)
|
||||||
|
if hours <= 0:
|
||||||
|
continue
|
||||||
|
if total_hours + hours > max_hours_per_day:
|
||||||
|
hours = max(0.0, max_hours_per_day - total_hours)
|
||||||
|
hours = round(round(hours / hour_step) * hour_step, 1)
|
||||||
|
if hours <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
description = str(item.get("description", "")).strip()
|
||||||
|
if not description or self._contains_cjk(description):
|
||||||
|
description = format_professional_description(task_by_id[task_id])
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
DayEntry(
|
||||||
|
date=day,
|
||||||
|
description=description,
|
||||||
|
take_hours=hours,
|
||||||
|
gitlab_task_id=task_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_hours += hours
|
||||||
|
|
||||||
|
if entries:
|
||||||
|
plan[day] = entries
|
||||||
|
|
||||||
|
if not any(plan.values()):
|
||||||
|
raise ValueError("Grok plan contained no valid entries")
|
||||||
|
|
||||||
|
return plan
|
||||||
|
|
||||||
|
def _contains_cjk(self, text: str) -> bool:
|
||||||
|
return bool(re.search(r"[\u4e00-\u9fff\u3400-\u4dbf]", text))
|
||||||
|
|
||||||
|
def _match_task_name(self, name: str, allowed: list[str], fallback: str) -> str:
|
||||||
|
cleaned = str(name or "").strip()
|
||||||
|
if cleaned in allowed:
|
||||||
|
return cleaned
|
||||||
|
lowered = cleaned.lower()
|
||||||
|
for option in allowed:
|
||||||
|
if lowered == option.lower():
|
||||||
|
return option
|
||||||
|
for option in allowed:
|
||||||
|
if lowered in option.lower() or option.lower() in lowered:
|
||||||
|
return option
|
||||||
|
return fallback if fallback in allowed else allowed[0]
|
||||||
|
|
||||||
|
async def classify_calendar_meetings(
|
||||||
|
self,
|
||||||
|
meetings: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
pts_task_options: list[str],
|
||||||
|
default_task_name: str,
|
||||||
|
) -> tuple[dict[str, dict[str, str]], dict[str, Any]]:
|
||||||
|
if not meetings:
|
||||||
|
return {}, {"source": "skipped", "reason": "no_meetings"}
|
||||||
|
|
||||||
|
allowed = pts_task_options or DEFAULT_PTS_TASK_TYPES
|
||||||
|
fallback = self._match_task_name(default_task_name, allowed, "Meeting")
|
||||||
|
|
||||||
|
prompt = f"""You classify Microsoft Teams / Outlook calendar events into PTS timesheet task types for Supermicro engineers.
|
||||||
|
|
||||||
|
For EACH meeting below, pick the best PTS task type and write a professional English work-log description.
|
||||||
|
|
||||||
|
## Allowed PTS task types (pick exactly one per meeting)
|
||||||
|
{json.dumps(allowed, ensure_ascii=False)}
|
||||||
|
|
||||||
|
## Classification guide
|
||||||
|
- Standups, syncs, team meetings, 1:1s, general discussions -> Meeting
|
||||||
|
- Sprint planning, roadmap, status review, project sync -> Project Management
|
||||||
|
- Design review, architecture review, UI/UX review -> Design
|
||||||
|
- Code review, PR review -> Code Review
|
||||||
|
- Training, workshop, onboarding session -> Training
|
||||||
|
- Customer call, support escalation, troubleshooting call -> Support or Trouble Shooting
|
||||||
|
- Interview (candidate) -> Interview
|
||||||
|
- Demo, POC review -> POC
|
||||||
|
- Requirements gathering, user research -> Survey
|
||||||
|
- Incident/postmortem investigation -> Investigating
|
||||||
|
- Documentation walkthrough -> Document
|
||||||
|
|
||||||
|
## Description rules
|
||||||
|
- English only, one sentence, 12–25 words, past tense
|
||||||
|
- Mention Teams/meeting context naturally
|
||||||
|
- Do NOT change take_hours
|
||||||
|
|
||||||
|
## Meetings
|
||||||
|
{json.dumps(meetings, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
Return JSON only:
|
||||||
|
{{
|
||||||
|
"summary": "brief note on classification approach",
|
||||||
|
"meetings": [
|
||||||
|
{{
|
||||||
|
"key": "YYYY-MM-DD#0",
|
||||||
|
"pts_task_name": "Meeting",
|
||||||
|
"description": "Attended sprint planning for SCC v3.9 release scope.",
|
||||||
|
"reasoning": "sprint planning keyword"
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw = await self._chat(prompt, temperature=0.3)
|
||||||
|
data = self._parse_json(raw)
|
||||||
|
classified: dict[str, dict[str, str]] = {}
|
||||||
|
for item in data.get("meetings", []):
|
||||||
|
key = str(item.get("key", "")).strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
description = str(item.get("description", "")).strip()
|
||||||
|
if not description or self._contains_cjk(description):
|
||||||
|
continue
|
||||||
|
task_name = self._match_task_name(
|
||||||
|
str(item.get("pts_task_name", "")),
|
||||||
|
allowed,
|
||||||
|
fallback,
|
||||||
|
)
|
||||||
|
classified[key] = {
|
||||||
|
"pts_task_name": task_name,
|
||||||
|
"description": description,
|
||||||
|
"reasoning": str(item.get("reasoning", "")).strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return classified, {
|
||||||
|
"source": "grok_meeting_classify",
|
||||||
|
"summary": data.get("summary", ""),
|
||||||
|
"classified_count": len(classified),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def diversify_descriptions(
|
||||||
|
self,
|
||||||
|
plan: dict[str, list[DayEntry]],
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]:
|
||||||
|
flat: list[dict[str, Any]] = []
|
||||||
|
for day_key in sorted(plan.keys()):
|
||||||
|
for idx, entry in enumerate(plan[day_key]):
|
||||||
|
flat.append(
|
||||||
|
{
|
||||||
|
"key": f"{day_key}#{idx}",
|
||||||
|
"date": day_key,
|
||||||
|
"gitlab_task_id": entry.gitlab_task_id,
|
||||||
|
"current_description": entry.description,
|
||||||
|
"take_hours": entry.take_hours,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(flat) <= 1:
|
||||||
|
return plan, {"source": "variation_skipped", "reason": "single_entry"}
|
||||||
|
|
||||||
|
task_by_id = {t.id: t for t in tasks}
|
||||||
|
task_context = [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"reference": t.reference,
|
||||||
|
"title": t.title,
|
||||||
|
"source": t.source,
|
||||||
|
}
|
||||||
|
for t in tasks
|
||||||
|
]
|
||||||
|
|
||||||
|
prompt = f"""Rewrite PTS timesheet descriptions for Supermicro engineering work logs.
|
||||||
|
|
||||||
|
For EACH entry below, produce a UNIQUE professional English description that:
|
||||||
|
- Keeps the same GitLab issue reference and gitlab_task_id when present
|
||||||
|
- Stays faithful to the task title / current description — describe plausible daily engineering work
|
||||||
|
- Uses different wording, verbs, and focus than every other entry in this batch
|
||||||
|
- Sounds like a real one-day work log (one sentence, 12–25 words), not copy-paste
|
||||||
|
- English only. Past tense or present-perfect. Start with a strong action verb.
|
||||||
|
- Do NOT change dates or take_hours
|
||||||
|
|
||||||
|
GitLab tasks (context):
|
||||||
|
{json.dumps(task_context, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
Entries to rewrite:
|
||||||
|
{json.dumps(flat, ensure_ascii=False, indent=2)}
|
||||||
|
|
||||||
|
Return JSON only:
|
||||||
|
{{
|
||||||
|
"summary": "short English note on variation approach",
|
||||||
|
"entries": [
|
||||||
|
{{"key": "YYYY-MM-DD#0", "description": "unique English work log"}}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
raw = await self._chat(prompt, temperature=0.65)
|
||||||
|
data = self._parse_json(raw)
|
||||||
|
rewritten = {
|
||||||
|
item["key"]: str(item.get("description", "")).strip()
|
||||||
|
for item in data.get("entries", [])
|
||||||
|
if item.get("key")
|
||||||
|
}
|
||||||
|
if not rewritten:
|
||||||
|
raise ValueError("Grok variation returned no entries")
|
||||||
|
|
||||||
|
new_plan: dict[str, list[DayEntry]] = {}
|
||||||
|
for day_key in sorted(plan.keys()):
|
||||||
|
new_plan[day_key] = []
|
||||||
|
for idx, entry in enumerate(plan[day_key]):
|
||||||
|
key = f"{day_key}#{idx}"
|
||||||
|
description = rewritten.get(key, entry.description)
|
||||||
|
if (not description or self._contains_cjk(description)) and entry.gitlab_task_id in task_by_id:
|
||||||
|
description = format_professional_description(task_by_id[entry.gitlab_task_id])
|
||||||
|
new_plan[day_key].append(
|
||||||
|
DayEntry(
|
||||||
|
date=day_key,
|
||||||
|
description=description,
|
||||||
|
take_hours=entry.take_hours,
|
||||||
|
gitlab_task_id=entry.gitlab_task_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_plan, {
|
||||||
|
"source": "grok_variation",
|
||||||
|
"summary": data.get("summary", ""),
|
||||||
|
"rewritten_count": len(rewritten),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
import holidays
|
||||||
|
|
||||||
|
|
||||||
|
def is_workday(d: date, country: str = "TW") -> bool:
|
||||||
|
if d.weekday() >= 5:
|
||||||
|
return False
|
||||||
|
tw_holidays = holidays.country_holidays(country, years={d.year})
|
||||||
|
return d not in tw_holidays
|
||||||
|
|
||||||
|
|
||||||
|
def iter_workdays(start: date, end: date, country: str = "TW") -> list[date]:
|
||||||
|
if start > end:
|
||||||
|
start, end = end, start
|
||||||
|
days: list[date] = []
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
if is_workday(current, country):
|
||||||
|
days.append(current)
|
||||||
|
current += timedelta(days=1)
|
||||||
|
return days
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.calendar_models import CalendarMeeting, format_meeting_description
|
||||||
|
from services.distributor import DayEntry, _from_units, _to_units
|
||||||
|
from services.grok_client import DEFAULT_PTS_TASK_TYPES, GrokClient
|
||||||
|
from services.teams_calendar_scraper import TeamsCalendarScraper
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_KEYWORD_TASK_RULES: list[tuple[re.Pattern[str], str]] = [
|
||||||
|
(re.compile(r"design|architecture|ux|ui review", re.I), "Design"),
|
||||||
|
(re.compile(r"code review|pr review", re.I), "Code Review"),
|
||||||
|
(re.compile(r"sprint|planning|roadmap|project|status", re.I), "Project Management"),
|
||||||
|
(re.compile(r"train|workshop|onboard|tutorial", re.I), "Training"),
|
||||||
|
(re.compile(r"interview", re.I), "Interview"),
|
||||||
|
(re.compile(r"support|customer|escalat", re.I), "Support"),
|
||||||
|
(re.compile(r"incident|postmortem|troubleshoot|debug", re.I), "Trouble Shooting"),
|
||||||
|
(re.compile(r"investigat|root cause", re.I), "Investigating"),
|
||||||
|
(re.compile(r"demo|poc|proof of concept", re.I), "POC"),
|
||||||
|
(re.compile(r"survey|research|requirement", re.I), "Survey"),
|
||||||
|
(re.compile(r"document|spec|write.?up", re.I), "Document"),
|
||||||
|
(re.compile(r"1:1|one on one|manager", re.I), "Manager Task"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _scale_entry_hours(entries: list[DayEntry], target_hours: float, hour_step: float) -> list[DayEntry]:
|
||||||
|
if not entries or target_hours <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
current = sum(entry.take_hours for entry in entries)
|
||||||
|
if current <= 0:
|
||||||
|
return entries
|
||||||
|
|
||||||
|
target_units = _to_units(target_hours, hour_step)
|
||||||
|
if target_units <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
scaled: list[DayEntry] = []
|
||||||
|
allocated = 0
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
if index == len(entries) - 1:
|
||||||
|
units = max(1, target_units - allocated)
|
||||||
|
else:
|
||||||
|
units = max(1, int(round((entry.take_hours / current) * target_units)))
|
||||||
|
allocated += units
|
||||||
|
scaled.append(
|
||||||
|
DayEntry(
|
||||||
|
date=entry.date,
|
||||||
|
description=entry.description,
|
||||||
|
take_hours=_from_units(units, hour_step),
|
||||||
|
gitlab_task_id=entry.gitlab_task_id,
|
||||||
|
pts_task_name=entry.pts_task_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return scaled
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_meeting_task(subject: str, allowed: list[str], fallback: str) -> str:
|
||||||
|
for pattern, task_name in _KEYWORD_TASK_RULES:
|
||||||
|
if pattern.search(subject):
|
||||||
|
if task_name in allowed:
|
||||||
|
return task_name
|
||||||
|
for option in allowed:
|
||||||
|
if option.lower() == task_name.lower():
|
||||||
|
return option
|
||||||
|
return fallback if fallback in allowed else (allowed[0] if allowed else "Meeting")
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_pts_task_options() -> list[str]:
|
||||||
|
try:
|
||||||
|
from services.pts_client import PTSClient
|
||||||
|
|
||||||
|
options = await PTSClient().get_task_type_options()
|
||||||
|
names = [str(opt.get("name", "")).strip() for opt in options if not opt.get("disable")]
|
||||||
|
return [name for name in names if name]
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Could not load PTS task options, using defaults: %s", exc)
|
||||||
|
return list(DEFAULT_PTS_TASK_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
async def _classify_meetings(
|
||||||
|
meeting_payload: list[dict],
|
||||||
|
*,
|
||||||
|
pts_task_options: list[str],
|
||||||
|
) -> tuple[dict[str, dict[str, str]], dict]:
|
||||||
|
fallback = settings.teams_meeting_task_name or "Meeting"
|
||||||
|
allowed = pts_task_options or list(DEFAULT_PTS_TASK_TYPES)
|
||||||
|
if fallback not in allowed:
|
||||||
|
fallback = _guess_meeting_task("", allowed, "Meeting")
|
||||||
|
|
||||||
|
grok = GrokClient()
|
||||||
|
if settings.use_llm and grok.enabled and meeting_payload:
|
||||||
|
try:
|
||||||
|
classified, meta = await grok.classify_calendar_meetings(
|
||||||
|
meeting_payload,
|
||||||
|
pts_task_options=allowed,
|
||||||
|
default_task_name=fallback,
|
||||||
|
)
|
||||||
|
if classified:
|
||||||
|
return classified, meta
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("LLM meeting classification failed, using rules: %s", exc)
|
||||||
|
|
||||||
|
classified = {}
|
||||||
|
for item in meeting_payload:
|
||||||
|
key = item["key"]
|
||||||
|
subject = str(item.get("subject", ""))
|
||||||
|
task_name = _guess_meeting_task(subject, allowed, fallback)
|
||||||
|
meeting = CalendarMeeting(
|
||||||
|
subject=subject,
|
||||||
|
start=item["start"],
|
||||||
|
end=item["end"],
|
||||||
|
hours=float(item["hours"]),
|
||||||
|
is_online=bool(item.get("is_online")),
|
||||||
|
)
|
||||||
|
classified[key] = {
|
||||||
|
"pts_task_name": task_name,
|
||||||
|
"description": format_meeting_description(meeting),
|
||||||
|
"reasoning": "rule_based_keyword",
|
||||||
|
}
|
||||||
|
|
||||||
|
return classified, {"source": "rule_based_meeting_classify", "classified_count": len(classified)}
|
||||||
|
|
||||||
|
|
||||||
|
async def merge_meetings_into_plan(
|
||||||
|
plan: dict[str, list[DayEntry]],
|
||||||
|
workday_keys: list[str],
|
||||||
|
) -> tuple[dict[str, list[DayEntry]], dict]:
|
||||||
|
client = TeamsCalendarScraper()
|
||||||
|
meta: dict = {"enabled": False}
|
||||||
|
|
||||||
|
if not client.enabled:
|
||||||
|
return plan, meta
|
||||||
|
|
||||||
|
try:
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
days = [date.fromisoformat(day_key) for day_key in workday_keys]
|
||||||
|
meetings_by_day = await client.fetch_meetings_for_days(days)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return plan, {"enabled": True, "error": str(exc)}
|
||||||
|
|
||||||
|
# Some calendar "meetings" are just automated reminders (e.g. HR clock-out
|
||||||
|
# nudges) that show up fine in the preview list but should never turn into
|
||||||
|
# a PTS work-log line. Filter those out here only — list_meetings()/preview
|
||||||
|
# keeps showing them since it reads fetch_meetings_for_days() directly.
|
||||||
|
exclude_keywords = [kw.lower() for kw in settings.teams_meeting_exclude_keyword_list]
|
||||||
|
excluded_count = 0
|
||||||
|
if exclude_keywords:
|
||||||
|
filtered_by_day: dict[str, list[CalendarMeeting]] = {}
|
||||||
|
for day_key, meetings in meetings_by_day.items():
|
||||||
|
kept = []
|
||||||
|
for meeting in meetings:
|
||||||
|
subject_l = meeting.subject.lower()
|
||||||
|
if any(kw in subject_l for kw in exclude_keywords):
|
||||||
|
excluded_count += 1
|
||||||
|
continue
|
||||||
|
kept.append(meeting)
|
||||||
|
filtered_by_day[day_key] = kept
|
||||||
|
meetings_by_day = filtered_by_day
|
||||||
|
|
||||||
|
pts_task_options = await _load_pts_task_options()
|
||||||
|
meeting_payload: list[dict] = []
|
||||||
|
for day_key in workday_keys:
|
||||||
|
for idx, meeting in enumerate(meetings_by_day.get(day_key, [])):
|
||||||
|
meeting_payload.append(
|
||||||
|
{
|
||||||
|
"key": f"{day_key}#{idx}",
|
||||||
|
"date": day_key,
|
||||||
|
"subject": meeting.subject,
|
||||||
|
"hours": meeting.hours,
|
||||||
|
"is_online": meeting.is_online,
|
||||||
|
"start": meeting.start.isoformat(),
|
||||||
|
"end": meeting.end.isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
classified, classify_meta = await _classify_meetings(
|
||||||
|
meeting_payload,
|
||||||
|
pts_task_options=pts_task_options,
|
||||||
|
)
|
||||||
|
max_hours = settings.max_hours_per_day
|
||||||
|
hour_step = settings.hour_step
|
||||||
|
merged: dict[str, list[DayEntry]] = {}
|
||||||
|
meeting_count = 0
|
||||||
|
meeting_hours_total = 0.0
|
||||||
|
task_breakdown: dict[str, int] = {}
|
||||||
|
|
||||||
|
for day_key in workday_keys:
|
||||||
|
meetings: list[CalendarMeeting] = meetings_by_day.get(day_key, [])
|
||||||
|
meeting_entries: list[DayEntry] = []
|
||||||
|
for idx, meeting in enumerate(meetings):
|
||||||
|
key = f"{day_key}#{idx}"
|
||||||
|
info = classified.get(key, {})
|
||||||
|
task_name = info.get("pts_task_name") or settings.teams_meeting_task_name or "Meeting"
|
||||||
|
description = info.get("description") or format_meeting_description(meeting)
|
||||||
|
meeting_entries.append(
|
||||||
|
DayEntry(
|
||||||
|
date=day_key,
|
||||||
|
description=description,
|
||||||
|
take_hours=meeting.hours,
|
||||||
|
gitlab_task_id=None,
|
||||||
|
pts_task_name=task_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task_breakdown[task_name] = task_breakdown.get(task_name, 0) + 1
|
||||||
|
|
||||||
|
meeting_hours = round(sum(entry.take_hours for entry in meeting_entries), 1)
|
||||||
|
meeting_count += len(meeting_entries)
|
||||||
|
meeting_hours_total += meeting_hours
|
||||||
|
|
||||||
|
if meeting_hours >= max_hours:
|
||||||
|
merged[day_key] = _scale_entry_hours(meeting_entries, max_hours, hour_step)
|
||||||
|
continue
|
||||||
|
|
||||||
|
remaining = max(0.0, max_hours - meeting_hours)
|
||||||
|
gitlab_entries = _scale_entry_hours(plan.get(day_key, []), remaining, hour_step)
|
||||||
|
merged[day_key] = meeting_entries + gitlab_entries
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"enabled": True,
|
||||||
|
"meeting_count": meeting_count,
|
||||||
|
"meeting_hours_total": round(meeting_hours_total, 1),
|
||||||
|
"task_breakdown": task_breakdown,
|
||||||
|
"classification": classify_meta,
|
||||||
|
"excluded_count": excluded_count,
|
||||||
|
}
|
||||||
|
return merged, meta
|
||||||
|
|
@ -0,0 +1,214 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.distributor import (
|
||||||
|
DayEntry,
|
||||||
|
build_plan_for_dates,
|
||||||
|
build_plan_for_day_list,
|
||||||
|
entries_from_last_state,
|
||||||
|
)
|
||||||
|
from services.gitlab_client import GitLabClient, GitLabTask
|
||||||
|
from services.grok_client import GrokClient
|
||||||
|
from services.holidays_util import iter_workdays
|
||||||
|
from services.meeting_planner import merge_meetings_into_plan
|
||||||
|
from services.state_store import get_last_entries
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Planner:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.gitlab = GitLabClient()
|
||||||
|
self.grok = GrokClient()
|
||||||
|
|
||||||
|
async def build_plan(
|
||||||
|
self,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
*,
|
||||||
|
use_llm: Optional[bool] = None,
|
||||||
|
use_last_if_empty: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
workdays = iter_workdays(start, end)
|
||||||
|
return await self._build_plan_for_days(
|
||||||
|
workdays,
|
||||||
|
use_llm=use_llm,
|
||||||
|
use_last_if_empty=use_last_if_empty,
|
||||||
|
rule_builder=lambda tasks, _: build_plan_for_dates(tasks, start, end),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def build_plan_for_days(
|
||||||
|
self,
|
||||||
|
days: list[date],
|
||||||
|
*,
|
||||||
|
use_llm: Optional[bool] = None,
|
||||||
|
use_last_if_empty: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
sorted_days = sorted(set(days))
|
||||||
|
return await self._build_plan_for_days(
|
||||||
|
sorted_days,
|
||||||
|
use_llm=use_llm,
|
||||||
|
use_last_if_empty=use_last_if_empty,
|
||||||
|
rule_builder=lambda tasks, workdays: build_plan_for_day_list(tasks, workdays),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _build_plan_for_days(
|
||||||
|
self,
|
||||||
|
workdays: list[date],
|
||||||
|
*,
|
||||||
|
use_llm: Optional[bool],
|
||||||
|
use_last_if_empty: bool,
|
||||||
|
rule_builder,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
gitlab_tasks = await self.gitlab.get_assigned_tasks()
|
||||||
|
|
||||||
|
if gitlab_tasks:
|
||||||
|
use_llm = settings.use_llm if use_llm is None else use_llm
|
||||||
|
if use_llm and self.grok.enabled:
|
||||||
|
try:
|
||||||
|
plan, analysis = await self.grok.plan_pts_entries(
|
||||||
|
gitlab_tasks,
|
||||||
|
workdays,
|
||||||
|
pts_project_name=settings.pts_project_name,
|
||||||
|
pts_task_name=settings.pts_default_task_name,
|
||||||
|
max_hours_per_day=settings.max_hours_per_day,
|
||||||
|
hour_step=settings.hour_step,
|
||||||
|
)
|
||||||
|
plan, analysis = await self._apply_variation(
|
||||||
|
plan,
|
||||||
|
gitlab_tasks,
|
||||||
|
analysis,
|
||||||
|
source="llm",
|
||||||
|
)
|
||||||
|
return await self._finalize_plan(
|
||||||
|
plan,
|
||||||
|
source="llm",
|
||||||
|
gitlab_tasks=gitlab_tasks,
|
||||||
|
workdays=workdays,
|
||||||
|
analysis=analysis,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("LLM planning failed, fallback to rules: %s", exc)
|
||||||
|
|
||||||
|
plan = rule_builder(gitlab_tasks, workdays)
|
||||||
|
plan, analysis = await self._apply_variation(
|
||||||
|
plan,
|
||||||
|
gitlab_tasks,
|
||||||
|
{"fallback_reason": "rule_based_distribution"},
|
||||||
|
source="rule_based",
|
||||||
|
)
|
||||||
|
return await self._finalize_plan(
|
||||||
|
plan,
|
||||||
|
source="rule_based",
|
||||||
|
gitlab_tasks=gitlab_tasks,
|
||||||
|
workdays=workdays,
|
||||||
|
analysis=analysis,
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_last_if_empty:
|
||||||
|
plan = entries_from_last_state(get_last_entries(), workdays)
|
||||||
|
plan, analysis = await self._apply_variation(
|
||||||
|
plan,
|
||||||
|
[],
|
||||||
|
{},
|
||||||
|
source="last_entries",
|
||||||
|
)
|
||||||
|
return await self._finalize_plan(
|
||||||
|
plan,
|
||||||
|
source="last_entries",
|
||||||
|
gitlab_tasks=[],
|
||||||
|
workdays=workdays,
|
||||||
|
analysis=analysis,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await self._finalize_plan(
|
||||||
|
{d.isoformat(): [] for d in workdays},
|
||||||
|
source="empty",
|
||||||
|
gitlab_tasks=[],
|
||||||
|
workdays=workdays,
|
||||||
|
analysis={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_duplicate_descriptions(self, plan: dict[str, list[DayEntry]]) -> bool:
|
||||||
|
descriptions = [entry.description for entries in plan.values() for entry in entries]
|
||||||
|
return len(descriptions) != len(set(descriptions))
|
||||||
|
|
||||||
|
async def _apply_variation(
|
||||||
|
self,
|
||||||
|
plan: dict[str, list[DayEntry]],
|
||||||
|
tasks: list[GitLabTask],
|
||||||
|
analysis: dict[str, Any],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
) -> tuple[dict[str, list[DayEntry]], dict[str, Any]]:
|
||||||
|
if not settings.use_description_variation or not self.grok.enabled:
|
||||||
|
return plan, analysis
|
||||||
|
|
||||||
|
entry_count = sum(len(entries) for entries in plan.values())
|
||||||
|
if entry_count <= 1:
|
||||||
|
return plan, {**analysis, "variation": {"source": "variation_skipped", "reason": "single_entry"}}
|
||||||
|
|
||||||
|
if source == "llm" and not self._has_duplicate_descriptions(plan):
|
||||||
|
return plan, {**analysis, "variation": {"source": "variation_skipped", "reason": "llm_unique"}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
plan, variation = await self.grok.diversify_descriptions(plan, tasks)
|
||||||
|
return plan, {**analysis, "variation": variation}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Description variation failed: %s", exc)
|
||||||
|
return plan, {**analysis, "variation": {"source": "variation_failed", "error": str(exc)}}
|
||||||
|
|
||||||
|
async def _finalize_plan(
|
||||||
|
self,
|
||||||
|
plan: dict[str, list[DayEntry]],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
gitlab_tasks: list[GitLabTask],
|
||||||
|
workdays: list[date],
|
||||||
|
analysis: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
workday_keys = [d.isoformat() for d in workdays]
|
||||||
|
plan, meeting_meta = await merge_meetings_into_plan(plan, workday_keys)
|
||||||
|
if meeting_meta:
|
||||||
|
analysis = {**analysis, "meetings": meeting_meta}
|
||||||
|
return self._wrap_plan(
|
||||||
|
plan,
|
||||||
|
source=source,
|
||||||
|
gitlab_tasks=gitlab_tasks,
|
||||||
|
workdays=workdays,
|
||||||
|
analysis=analysis,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wrap_plan(
|
||||||
|
self,
|
||||||
|
plan: dict[str, list[DayEntry]],
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
gitlab_tasks: list[GitLabTask],
|
||||||
|
workdays: list[date],
|
||||||
|
analysis: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": source,
|
||||||
|
"gitlab_task_count": len(gitlab_tasks),
|
||||||
|
"workdays": [d.isoformat() for d in workdays],
|
||||||
|
"llm_enabled": self.grok.enabled,
|
||||||
|
"analysis": analysis,
|
||||||
|
"plan": {
|
||||||
|
day: [
|
||||||
|
{
|
||||||
|
"description": entry.description,
|
||||||
|
"take_hours": entry.take_hours,
|
||||||
|
"gitlab_task_id": entry.gitlab_task_id,
|
||||||
|
"pts_task_name": entry.pts_task_name,
|
||||||
|
}
|
||||||
|
for entry in entries
|
||||||
|
]
|
||||||
|
for day, entries in plan.items()
|
||||||
|
},
|
||||||
|
"_plan_entries": plan,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.secret_box import decrypt_secret
|
||||||
|
from services.state_store import get_pts_session, set_pts_session
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TASK_NAME_ALIASES = {
|
||||||
|
"development": "Implement",
|
||||||
|
"dev": "Implement",
|
||||||
|
}
|
||||||
|
|
||||||
|
_SESSION_HELP = (
|
||||||
|
"PTS session not found. Enter PTS account/password on the web UI, "
|
||||||
|
"or sync via Chrome extension."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pts_base_url(url: str) -> str:
|
||||||
|
"""PTS REST APIs are hosted under /PTS, not the site root."""
|
||||||
|
base = url.rstrip("/")
|
||||||
|
if base.lower().endswith("/pts"):
|
||||||
|
return base
|
||||||
|
return f"{base}/PTS"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_session_payload(data: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("PTS login returned unexpected payload")
|
||||||
|
access = data.get("accessToken") or data.get("AccessToken")
|
||||||
|
if not access:
|
||||||
|
raise ValueError("PTS login did not return accessToken")
|
||||||
|
session = dict(data)
|
||||||
|
session["accessToken"] = access
|
||||||
|
if data.get("refreshToken") or data.get("RefreshToken"):
|
||||||
|
session["refreshToken"] = data.get("refreshToken") or data.get("RefreshToken")
|
||||||
|
group = data.get("groupCode") or data.get("GroupCode")
|
||||||
|
if not group and session.get("accessToken") and session["accessToken"].count(".") >= 2:
|
||||||
|
try:
|
||||||
|
payload = session["accessToken"].split(".")[1]
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
decoded = json.loads(base64.urlsafe_b64decode(payload))
|
||||||
|
group = decoded.get("groupCode")
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
group = None
|
||||||
|
if group:
|
||||||
|
session["groupCode"] = str(group)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
class PTSClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.base_url = normalize_pts_base_url(settings.pts_url)
|
||||||
|
|
||||||
|
def _require_session(self) -> dict[str, Any]:
|
||||||
|
session = get_pts_session()
|
||||||
|
if not session or not session.get("accessToken"):
|
||||||
|
raise ValueError(_SESSION_HELP)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def _auth_headers(self) -> dict[str, str]:
|
||||||
|
session = self._require_session()
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {session['accessToken']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _group_code_from_session(self, session: dict[str, Any]) -> str | None:
|
||||||
|
group_code = session.get("groupCode")
|
||||||
|
if group_code:
|
||||||
|
return str(group_code)
|
||||||
|
|
||||||
|
refresh_token = session.get("refreshToken")
|
||||||
|
if not refresh_token or refresh_token.count(".") < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = refresh_token.split(".")[1]
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
decoded = json.loads(base64.urlsafe_b64decode(payload))
|
||||||
|
group_code = decoded.get("groupCode")
|
||||||
|
return str(group_code) if group_code else None
|
||||||
|
except (json.JSONDecodeError, ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _stored_credentials(self) -> tuple[str, str] | None:
|
||||||
|
username = (getattr(settings, "pts_username", "") or "").strip()
|
||||||
|
encrypted = getattr(settings, "pts_password", "") or ""
|
||||||
|
if not username or not encrypted:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
password = decrypt_secret(encrypted)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not password:
|
||||||
|
return None
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
group_code: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
username = username.strip()
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError("PTS username and password are required")
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
session: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = await self._login_windows_ntlm(username, password)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"Windows/NTLM: {exc}")
|
||||||
|
logger.info("PTS Windows auth failed: %s", exc)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
try:
|
||||||
|
session = await self._login_forms(username, password)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"Forms: {exc}")
|
||||||
|
logger.info("PTS Forms auth failed: %s", exc)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
detail = "; ".join(errors) if errors else "unknown error"
|
||||||
|
raise ValueError(
|
||||||
|
"PTS login failed. Check account/password (Windows domain format "
|
||||||
|
f"DOMAIN\\user is supported). Details: {detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if group_code:
|
||||||
|
session = await self.switch_group(session, group_code)
|
||||||
|
|
||||||
|
set_pts_session(session)
|
||||||
|
logger.info("PTS login succeeded for %s", username)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "PTS login succeeded",
|
||||||
|
"group_code": session.get("groupCode"),
|
||||||
|
"has_session": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def login_with_stored_credentials(self) -> dict[str, Any]:
|
||||||
|
creds = self._stored_credentials()
|
||||||
|
if not creds:
|
||||||
|
raise ValueError(
|
||||||
|
"No stored PTS credentials. Enter username/password on the web UI."
|
||||||
|
)
|
||||||
|
username, password = creds
|
||||||
|
return await self.login(username, password)
|
||||||
|
|
||||||
|
async def _login_windows_ntlm(self, username: str, password: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from httpx_ntlm import HttpNtlmAuth
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"httpx-ntlm is not installed. Run: pip install httpx-ntlm"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
url = f"{self.base_url}/api/Login/WindowsAuthentication"
|
||||||
|
auth = HttpNtlmAuth(username, password)
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, auth=auth) as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
raise ValueError("invalid username/password or NTLM denied")
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return _normalize_session_payload(data)
|
||||||
|
|
||||||
|
async def _login_forms(self, username: str, password: str) -> dict[str, Any]:
|
||||||
|
url = f"{self.base_url}/api/Login/FormsAuthentication"
|
||||||
|
bodies = [
|
||||||
|
{"userName": username, "password": password},
|
||||||
|
{"username": username, "password": password},
|
||||||
|
{"UserName": username, "Password": password},
|
||||||
|
{"account": username, "password": password},
|
||||||
|
{"id": username, "password": password},
|
||||||
|
{"employeeId": username, "password": password},
|
||||||
|
]
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
last_status = None
|
||||||
|
for body in bodies:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
last_status = response.status_code
|
||||||
|
if response.status_code == 404:
|
||||||
|
break
|
||||||
|
if response.status_code >= 400:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(data, dict) and (data.get("accessToken") or data.get("AccessToken")):
|
||||||
|
return _normalize_session_payload(data)
|
||||||
|
raise ValueError(f"FormsAuthentication unavailable (HTTP {last_status})")
|
||||||
|
|
||||||
|
async def switch_group(self, session: dict[str, Any], group_code: str) -> dict[str, Any]:
|
||||||
|
url = f"{self.base_url}/api/Login/SwitchGroup"
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {session['accessToken']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={"groupCode": group_code, "accessToken": session["accessToken"]},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if not data:
|
||||||
|
session["groupCode"] = group_code
|
||||||
|
return session
|
||||||
|
return _normalize_session_payload(data)
|
||||||
|
|
||||||
|
async def refresh_access_token(self) -> str:
|
||||||
|
session = self._require_session()
|
||||||
|
refresh_token = session.get("refreshToken")
|
||||||
|
group_code = self._group_code_from_session(session)
|
||||||
|
if not refresh_token or not group_code:
|
||||||
|
return await self._relogin_or_raise(
|
||||||
|
"PTS session expired and cannot refresh. Re-login with password or Extension."
|
||||||
|
)
|
||||||
|
|
||||||
|
url = f"{self.base_url}/api/Login/RefreshToken"
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
json={"refreshToken": refresh_token, "groupCode": group_code},
|
||||||
|
)
|
||||||
|
if response.status_code == 401:
|
||||||
|
return await self._relogin_or_raise(
|
||||||
|
"PTS session expired. Re-login with password or Extension."
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
access_token = data.get("accessToken")
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError("PTS refresh did not return accessToken")
|
||||||
|
|
||||||
|
session["accessToken"] = access_token
|
||||||
|
session["groupCode"] = group_code
|
||||||
|
set_pts_session(session)
|
||||||
|
logger.info("PTS access token refreshed")
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
async def _relogin_or_raise(self, message: str) -> str:
|
||||||
|
if self._stored_credentials():
|
||||||
|
await self.login_with_stored_credentials()
|
||||||
|
session = get_pts_session() or {}
|
||||||
|
token = session.get("accessToken")
|
||||||
|
if token:
|
||||||
|
return str(token)
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
json_body: Any = None,
|
||||||
|
retry_on_unauthorized: bool = True,
|
||||||
|
) -> Any:
|
||||||
|
url = f"{normalize_pts_base_url(self.base_url)}{path}"
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
for attempt in range(2):
|
||||||
|
response = await client.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=self._auth_headers(),
|
||||||
|
json=json_body,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
response.status_code == 401
|
||||||
|
and retry_on_unauthorized
|
||||||
|
and attempt == 0
|
||||||
|
and path != "/api/Login/RefreshToken"
|
||||||
|
):
|
||||||
|
await self.refresh_access_token()
|
||||||
|
continue
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise ValueError(
|
||||||
|
"PTS session expired. Re-login with password or Extension."
|
||||||
|
)
|
||||||
|
if response.status_code == 404 and "/PTS/" not in str(response.request.url):
|
||||||
|
raise ValueError(
|
||||||
|
"PTS API returned 404 because the URL is missing /PTS. "
|
||||||
|
f"Set PTS URL to {normalize_pts_base_url(settings.pts_url)} "
|
||||||
|
"and run: make restart"
|
||||||
|
) from None
|
||||||
|
response.raise_for_status()
|
||||||
|
content_type = response.headers.get("content-type", "")
|
||||||
|
if "application/json" in content_type:
|
||||||
|
return response.json()
|
||||||
|
return response.text
|
||||||
|
raise RuntimeError("PTS request retry loop exhausted")
|
||||||
|
|
||||||
|
async def get_project_options(self) -> list[dict[str, Any]]:
|
||||||
|
result = await self._request("GET", "/api/ProjectCode/GetProjectOptionsForPersonal")
|
||||||
|
return result or []
|
||||||
|
|
||||||
|
async def get_task_type_options(self) -> list[dict[str, Any]]:
|
||||||
|
result = await self._request("GET", "/api/TaskType/GetTaskTypeOptions")
|
||||||
|
return result or []
|
||||||
|
|
||||||
|
async def search_reports(
|
||||||
|
self,
|
||||||
|
begin_date: str,
|
||||||
|
end_date: str,
|
||||||
|
*,
|
||||||
|
project_code: str | None = None,
|
||||||
|
task_id: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"beginDate": begin_date,
|
||||||
|
"endDate": end_date,
|
||||||
|
}
|
||||||
|
if project_code:
|
||||||
|
payload["projectCode"] = project_code
|
||||||
|
if task_id is not None:
|
||||||
|
payload["taskId"] = task_id
|
||||||
|
result = await self._request("POST", "/api/Report/SearchForMember", json_body=payload)
|
||||||
|
return result or []
|
||||||
|
|
||||||
|
async def create_report(self, payload: dict[str, Any]) -> int | str | None:
|
||||||
|
result = await self._request("POST", "/api/Report/CreateReport", json_body=payload)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return result.get("id")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def resolve_project_code(self, project_name: str | None = None) -> str:
|
||||||
|
name = project_name or settings.pts_project_name
|
||||||
|
options = await self.get_project_options()
|
||||||
|
for opt in options:
|
||||||
|
if opt.get("name") == name or opt.get("code") == name:
|
||||||
|
return opt["code"]
|
||||||
|
lowered = name.lower()
|
||||||
|
for opt in options:
|
||||||
|
if lowered in str(opt.get("name", "")).lower():
|
||||||
|
return opt["code"]
|
||||||
|
available = [f"{o.get('name')} ({o.get('code')})" for o in options[:20]]
|
||||||
|
raise ValueError(
|
||||||
|
f"Project '{name}' not found. Available: {', '.join(available)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def resolve_task_id(self, task_name: str | None = None) -> int:
|
||||||
|
name = task_name or settings.pts_default_task_name
|
||||||
|
alias = TASK_NAME_ALIASES.get(name.lower())
|
||||||
|
if alias:
|
||||||
|
name = alias
|
||||||
|
|
||||||
|
options = await self.get_task_type_options()
|
||||||
|
for opt in options:
|
||||||
|
if opt.get("name") == name:
|
||||||
|
return int(opt["id"])
|
||||||
|
lowered = name.lower()
|
||||||
|
for opt in options:
|
||||||
|
if lowered in str(opt.get("name", "")).lower():
|
||||||
|
return int(opt["id"])
|
||||||
|
available = [o.get("name") for o in options if not o.get("disable")]
|
||||||
|
raise ValueError(
|
||||||
|
f"Task '{task_name}' not found. Available: {', '.join(available)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def verify_connection(self) -> dict[str, Any]:
|
||||||
|
projects = await self.get_project_options()
|
||||||
|
tasks = await self.get_task_type_options()
|
||||||
|
project_code = await self.resolve_project_code()
|
||||||
|
task_id = await self.resolve_task_id()
|
||||||
|
task_name = next(
|
||||||
|
(t.get("name") for t in tasks if int(t.get("id", -1)) == task_id),
|
||||||
|
str(task_id),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"base_url": self.base_url,
|
||||||
|
"project_count": len(projects),
|
||||||
|
"task_type_count": len(tasks),
|
||||||
|
"resolved_project": project_code,
|
||||||
|
"resolved_task_id": task_id,
|
||||||
|
"resolved_task_name": task_name,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from services.fill_service import FillService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_scheduler: AsyncIOScheduler | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def _auto_fill_job() -> None:
|
||||||
|
service = FillService()
|
||||||
|
result = await service.fill_today()
|
||||||
|
logger.info("Auto fill result: %s", result.get("message") or result)
|
||||||
|
|
||||||
|
|
||||||
|
def start_scheduler() -> AsyncIOScheduler | None:
|
||||||
|
global _scheduler
|
||||||
|
|
||||||
|
if not settings.auto_fill_enabled:
|
||||||
|
logger.info("Auto fill scheduler disabled")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _scheduler is not None:
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
_scheduler = AsyncIOScheduler()
|
||||||
|
_scheduler.add_job(
|
||||||
|
_auto_fill_job,
|
||||||
|
CronTrigger(
|
||||||
|
hour=settings.auto_fill_hour,
|
||||||
|
minute=settings.auto_fill_minute,
|
||||||
|
),
|
||||||
|
id="daily_pts_fill",
|
||||||
|
replace_existing=True,
|
||||||
|
)
|
||||||
|
_scheduler.start()
|
||||||
|
logger.info(
|
||||||
|
"Scheduler started: daily fill at %02d:%02d",
|
||||||
|
settings.auto_fill_hour,
|
||||||
|
settings.auto_fill_minute,
|
||||||
|
)
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
def stop_scheduler() -> None:
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
_scheduler.shutdown(wait=False)
|
||||||
|
_scheduler = None
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule_scheduler() -> None:
|
||||||
|
from config import reload_settings
|
||||||
|
|
||||||
|
reload_settings()
|
||||||
|
stop_scheduler()
|
||||||
|
start_scheduler()
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from paths import DATA_DIR
|
||||||
|
|
||||||
|
KEY_FILE = DATA_DIR / ".secret_key"
|
||||||
|
ENCRYPTED_PREFIX = "enc:"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_key() -> bytes:
|
||||||
|
KEY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if KEY_FILE.exists():
|
||||||
|
raw = KEY_FILE.read_bytes().strip()
|
||||||
|
if raw:
|
||||||
|
return raw
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
KEY_FILE.write_bytes(key)
|
||||||
|
try:
|
||||||
|
KEY_FILE.chmod(0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
return Fernet(_ensure_key())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plain: str) -> str:
|
||||||
|
if not plain:
|
||||||
|
return ""
|
||||||
|
if plain.startswith(ENCRYPTED_PREFIX):
|
||||||
|
return plain
|
||||||
|
token = _fernet().encrypt(plain.encode("utf-8")).decode("ascii")
|
||||||
|
return f"{ENCRYPTED_PREFIX}{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if not value.startswith(ENCRYPTED_PREFIX):
|
||||||
|
return value
|
||||||
|
token = value[len(ENCRYPTED_PREFIX) :].encode("ascii")
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(token).decode("utf-8")
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"Could not decrypt stored password. Secret key may have changed; re-enter password."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def is_encrypted(value: str) -> bool:
|
||||||
|
return bool(value) and value.startswith(ENCRYPTED_PREFIX)
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdatePayload(BaseModel):
|
||||||
|
gitlab_url: Optional[str] = None
|
||||||
|
gitlab_token: Optional[str] = None
|
||||||
|
gitlab_project_path: Optional[str] = None
|
||||||
|
gitlab_board_id: Optional[int] = None
|
||||||
|
gitlab_board_labels: Optional[str] = None
|
||||||
|
gitlab_milestone_title: Optional[str] = None
|
||||||
|
|
||||||
|
pts_url: Optional[str] = None
|
||||||
|
pts_project_name: Optional[str] = None
|
||||||
|
pts_default_task_name: Optional[str] = None
|
||||||
|
pts_username: Optional[str] = None
|
||||||
|
pts_password: Optional[str] = None
|
||||||
|
|
||||||
|
auto_fill_enabled: Optional[bool] = None
|
||||||
|
auto_fill_hour: Optional[int] = Field(default=None, ge=0, le=23)
|
||||||
|
auto_fill_minute: Optional[int] = Field(default=None, ge=0, le=59)
|
||||||
|
|
||||||
|
max_hours_per_day: Optional[float] = Field(default=None, gt=0, le=24)
|
||||||
|
hour_step: Optional[float] = Field(default=None, gt=0, le=8)
|
||||||
|
|
||||||
|
xai_api_key: Optional[str] = None
|
||||||
|
xai_base_url: Optional[str] = None
|
||||||
|
grok_model: Optional[str] = None
|
||||||
|
use_llm: Optional[bool] = None
|
||||||
|
use_description_variation: Optional[bool] = None
|
||||||
|
|
||||||
|
teams_calendar_enabled: Optional[bool] = None
|
||||||
|
teams_username: Optional[str] = None
|
||||||
|
teams_password: Optional[str] = None
|
||||||
|
teams_calendar_timezone: Optional[str] = None
|
||||||
|
teams_meeting_task_name: Optional[str] = None
|
||||||
|
teams_headless: Optional[bool] = None
|
||||||
|
teams_meeting_exclude_keywords: Optional[str] = None
|
||||||
|
azure_client_id: Optional[str] = None
|
||||||
|
azure_tenant_id: Optional[str] = None
|
||||||
|
|
||||||
|
compbase_list_url: Optional[str] = None
|
||||||
|
compbase_fill_url: Optional[str] = None
|
||||||
|
compbase_username: Optional[str] = None
|
||||||
|
compbase_password: Optional[str] = None
|
||||||
|
compbase_default_days: Optional[int] = Field(default=None, ge=1, le=62)
|
||||||
|
compbase_out_time_mode: Optional[str] = None
|
||||||
|
|
||||||
|
def to_update_dict(self) -> dict[str, Any]:
|
||||||
|
return {k: v for k, v in self.model_dump().items() if v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
class DatesFilePayload(BaseModel):
|
||||||
|
content: str = ""
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from config import CONFIGURABLE_FIELDS, reload_settings
|
||||||
|
from paths import get_dates_file_path
|
||||||
|
from services.pts_client import normalize_pts_base_url
|
||||||
|
from services.secret_box import encrypt_secret
|
||||||
|
from services.settings_store import (
|
||||||
|
PASSWORD_FIELDS,
|
||||||
|
is_masked_or_empty,
|
||||||
|
load_overrides,
|
||||||
|
public_view,
|
||||||
|
save_overrides,
|
||||||
|
SECRET_FIELDS,
|
||||||
|
)
|
||||||
|
from config import get_settings_dict
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsService:
|
||||||
|
def get_public_settings(self) -> dict[str, Any]:
|
||||||
|
return public_view(get_settings_dict())
|
||||||
|
|
||||||
|
def save_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
overrides = load_overrides()
|
||||||
|
updates = {k: payload[k] for k in CONFIGURABLE_FIELDS if k in payload}
|
||||||
|
|
||||||
|
for field in SECRET_FIELDS:
|
||||||
|
if field in updates and is_masked_or_empty(updates[field]):
|
||||||
|
updates.pop(field)
|
||||||
|
elif field in updates and updates[field] is not None:
|
||||||
|
# strip whitespace / accidental quotes from pasted tokens
|
||||||
|
updates[field] = str(updates[field]).strip().strip("\"'")
|
||||||
|
|
||||||
|
for field in PASSWORD_FIELDS:
|
||||||
|
if field in updates and updates[field]:
|
||||||
|
updates[field] = encrypt_secret(str(updates[field]))
|
||||||
|
|
||||||
|
if "pts_url" in updates and updates["pts_url"]:
|
||||||
|
updates["pts_url"] = normalize_pts_base_url(str(updates["pts_url"]))
|
||||||
|
|
||||||
|
overrides.update(updates)
|
||||||
|
save_overrides(overrides)
|
||||||
|
|
||||||
|
from services.scheduler import reschedule_scheduler
|
||||||
|
|
||||||
|
reload_settings()
|
||||||
|
reschedule_scheduler()
|
||||||
|
return self.get_public_settings()
|
||||||
|
|
||||||
|
def get_dates_file(self) -> str:
|
||||||
|
path = get_dates_file_path()
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def save_dates_file(self, content: str) -> None:
|
||||||
|
path = get_dates_file_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from paths import DATA_DIR
|
||||||
|
|
||||||
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
||||||
|
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
SECRET_FIELDS = {
|
||||||
|
"gitlab_token",
|
||||||
|
"xai_api_key",
|
||||||
|
"pts_password",
|
||||||
|
"teams_password",
|
||||||
|
"compbase_password",
|
||||||
|
}
|
||||||
|
PASSWORD_FIELDS = {"pts_password", "teams_password", "compbase_password"}
|
||||||
|
MASK_PLACEHOLDER = "********"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def load_overrides() -> dict[str, Any]:
|
||||||
|
if not SETTINGS_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
payload = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
|
||||||
|
return payload.get("values", payload) if isinstance(payload, dict) else {}
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_overrides(values: dict[str, Any]) -> None:
|
||||||
|
SETTINGS_FILE.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"updated_at": _now_iso(), "values": values},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret(value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if len(value) <= 10:
|
||||||
|
return MASK_PLACEHOLDER
|
||||||
|
return f"{value[:6]}...{value[-4:]}"
|
||||||
|
|
||||||
|
|
||||||
|
def is_masked_or_empty(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return True
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return True
|
||||||
|
if text == MASK_PLACEHOLDER:
|
||||||
|
return True
|
||||||
|
if "..." in text and len(text) < 30:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def merge_secret_updates(
|
||||||
|
current: dict[str, Any],
|
||||||
|
updates: dict[str, Any],
|
||||||
|
existing_secrets: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
merged = {**current, **updates}
|
||||||
|
for field in SECRET_FIELDS:
|
||||||
|
if field in updates and is_masked_or_empty(updates[field]):
|
||||||
|
if existing_secrets.get(field):
|
||||||
|
merged[field] = existing_secrets[field]
|
||||||
|
else:
|
||||||
|
merged.pop(field, None)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def public_view(settings: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
public = dict(settings)
|
||||||
|
for field in SECRET_FIELDS:
|
||||||
|
raw = public.get(field, "")
|
||||||
|
public[f"{field}_set"] = bool(raw)
|
||||||
|
public[field] = mask_secret(raw) if raw else ""
|
||||||
|
public["gitlab_board_label_list"] = [
|
||||||
|
part.strip()
|
||||||
|
for part in str(public.get("gitlab_board_labels", "")).split(",")
|
||||||
|
if part.strip()
|
||||||
|
]
|
||||||
|
public["auto_fill_time"] = (
|
||||||
|
f"{int(public.get('auto_fill_hour', 18)):02d}:"
|
||||||
|
f"{int(public.get('auto_fill_minute', 0)):02d}"
|
||||||
|
)
|
||||||
|
return public
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from paths import DATA_DIR
|
||||||
|
|
||||||
|
STATE_FILE = DATA_DIR / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> dict[str, Any]:
|
||||||
|
if not STATE_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(state: dict[str, Any]) -> None:
|
||||||
|
STATE_FILE.write_text(
|
||||||
|
json.dumps(state, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pts_session() -> dict[str, Any] | None:
|
||||||
|
state = load_state()
|
||||||
|
session = state.get("pts_session")
|
||||||
|
return session if isinstance(session, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_pts_session(session: dict[str, Any]) -> None:
|
||||||
|
state = load_state()
|
||||||
|
state["pts_session"] = session
|
||||||
|
state["pts_session_updated_at"] = _now_iso()
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_pts_session() -> None:
|
||||||
|
state = load_state()
|
||||||
|
state.pop("pts_session", None)
|
||||||
|
state.pop("pts_session_updated_at", None)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_teams_browser_meta() -> dict[str, Any]:
|
||||||
|
state = load_state()
|
||||||
|
meta = state.get("teams_browser")
|
||||||
|
return meta if isinstance(meta, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def set_teams_browser_meta(meta: dict[str, Any]) -> None:
|
||||||
|
state = load_state()
|
||||||
|
state["teams_browser"] = meta
|
||||||
|
state["teams_browser_updated_at"] = _now_iso()
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_teams_browser_meta() -> None:
|
||||||
|
state = load_state()
|
||||||
|
state.pop("teams_browser", None)
|
||||||
|
state.pop("teams_browser_updated_at", None)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_entries() -> list[dict[str, Any]]:
|
||||||
|
state = load_state()
|
||||||
|
entries = state.get("last_entries")
|
||||||
|
return entries if isinstance(entries, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def set_last_entries(entries: list[dict[str, Any]]) -> None:
|
||||||
|
state = load_state()
|
||||||
|
state["last_entries"] = entries
|
||||||
|
state["last_entries_updated_at"] = _now_iso()
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_filled_dates() -> set[str]:
|
||||||
|
state = load_state()
|
||||||
|
dates = state.get("filled_dates")
|
||||||
|
if not isinstance(dates, list):
|
||||||
|
return set()
|
||||||
|
return set(dates)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_dates_filled(dates: list[str]) -> None:
|
||||||
|
state = load_state()
|
||||||
|
existing = set(state.get("filled_dates") or [])
|
||||||
|
existing.update(dates)
|
||||||
|
state["filled_dates"] = sorted(existing)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph_session() -> dict[str, Any] | None:
|
||||||
|
state = load_state()
|
||||||
|
session = state.get("graph_session")
|
||||||
|
return session if isinstance(session, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_graph_session(session: dict[str, Any]) -> None:
|
||||||
|
state = load_state()
|
||||||
|
state["graph_session"] = session
|
||||||
|
state["graph_session_updated_at"] = _now_iso()
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_graph_session() -> None:
|
||||||
|
state = load_state()
|
||||||
|
state.pop("graph_session", None)
|
||||||
|
state.pop("graph_session_updated_at", None)
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph_device_auth() -> dict[str, Any] | None:
|
||||||
|
state = load_state()
|
||||||
|
pending = state.get("graph_device_auth")
|
||||||
|
return pending if isinstance(pending, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_graph_device_auth(payload: dict[str, Any]) -> None:
|
||||||
|
state = load_state()
|
||||||
|
state["graph_device_auth"] = payload
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_graph_device_auth() -> None:
|
||||||
|
state = load_state()
|
||||||
|
state.pop("graph_device_auth", None)
|
||||||
|
save_state(state)
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Ephemeral: no host volume. /data is tmpfs — gone when container is removed.
|
||||||
|
services:
|
||||||
|
pts:
|
||||||
|
build: .
|
||||||
|
container_name: pts
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8765}:8765"
|
||||||
|
# noVNC:Teams MFA 互動畫面(點擊/輸入驗證碼)
|
||||||
|
- "${TEAMS_VNC_PORT:-6080}:6080"
|
||||||
|
environment:
|
||||||
|
PTS_DATA_DIR: /data
|
||||||
|
PTS_RELOAD: "false"
|
||||||
|
PTS_IN_DOCKER: "true"
|
||||||
|
DISPLAY: ":99"
|
||||||
|
TEAMS_HEADLESS: "false"
|
||||||
|
TEAMS_VNC_PORT: "6080"
|
||||||
|
# Optional tokens from host shell / project .env (compose substitution only; not a data mount)
|
||||||
|
GITLAB_TOKEN: ${GITLAB_TOKEN:-}
|
||||||
|
GITLAB_URL: ${GITLAB_URL:-}
|
||||||
|
GITLAB_PROJECT_PATH: ${GITLAB_PROJECT_PATH:-}
|
||||||
|
XAI_API_KEY: ${XAI_API_KEY:-}
|
||||||
|
XAI_BASE_URL: ${XAI_BASE_URL:-}
|
||||||
|
GROK_MODEL: ${GROK_MODEL:-}
|
||||||
|
PTS_URL: ${PTS_URL:-}
|
||||||
|
PTS_PROJECT_NAME: ${PTS_PROJECT_NAME:-}
|
||||||
|
tmpfs:
|
||||||
|
- /data:size=64m,mode=1777
|
||||||
|
# Do not mount host dirs. Session / settings only live in container tmpfs.
|
||||||
|
shm_size: "256mb"
|
||||||
|
restart: "no"
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
# Ephemeral container: /data is typically tmpfs; never require a host mount.
|
||||||
|
DATA_DIR="${PTS_DATA_DIR:-/data}"
|
||||||
|
mkdir -p "$DATA_DIR"
|
||||||
|
|
||||||
|
# Empty local files only inside the container (discarded when container is removed).
|
||||||
|
if [ ! -f "$DATA_DIR/.env" ]; then
|
||||||
|
touch "$DATA_DIR/.env"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$DATA_DIR/dates.txt" ]; then
|
||||||
|
touch "$DATA_DIR/dates.txt"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Virtual display + noVNC so Teams MFA can be completed interactively in Docker.
|
||||||
|
# Chromium runs headed on DISPLAY; user opens :6080 in browser to click/type.
|
||||||
|
DISPLAY_NUM="${DISPLAY#:}"
|
||||||
|
DISPLAY_NUM="${DISPLAY_NUM:-99}"
|
||||||
|
export DISPLAY=":${DISPLAY_NUM}"
|
||||||
|
VNC_PORT="${TEAMS_VNC_PORT:-6080}"
|
||||||
|
RFB_PORT="${TEAMS_RFB_PORT:-5900}"
|
||||||
|
|
||||||
|
NOVNC_WEB=""
|
||||||
|
for d in /usr/share/novnc /usr/share/novnc/web /usr/share/webapps/novnc; do
|
||||||
|
if [ -d "$d" ] && { [ -f "$d/vnc.html" ] || [ -f "$d/vnc_lite.html" ]; }; then
|
||||||
|
NOVNC_WEB="$d"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "$NOVNC_WEB" ] && [ -d /usr/share/novnc ]; then
|
||||||
|
NOVNC_WEB=/usr/share/novnc
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_vnc_stack() {
|
||||||
|
if ! command -v Xvfb >/dev/null 2>&1; then
|
||||||
|
echo "WARN: Xvfb not installed — Teams MFA interactive view unavailable"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Already running (container restart of app only)
|
||||||
|
if [ -S "/tmp/.X11-unix/X${DISPLAY_NUM}" ] 2>/dev/null || \
|
||||||
|
pgrep -x Xvfb >/dev/null 2>&1; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "==> Xvfb ${DISPLAY} (Teams MFA virtual display)"
|
||||||
|
Xvfb "${DISPLAY}" -screen 0 1400x900x24 -ac +extension RANDR -nolisten tcp >/tmp/xvfb.log 2>&1 &
|
||||||
|
sleep 0.5
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v fluxbox >/dev/null 2>&1; then
|
||||||
|
if ! pgrep -x fluxbox >/dev/null 2>&1; then
|
||||||
|
fluxbox >/tmp/fluxbox.log 2>&1 &
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v x11vnc >/dev/null 2>&1; then
|
||||||
|
if ! pgrep -x x11vnc >/dev/null 2>&1; then
|
||||||
|
echo "==> x11vnc :${RFB_PORT}"
|
||||||
|
x11vnc -display "${DISPLAY}" -forever -shared -rfbport "${RFB_PORT}" \
|
||||||
|
-nopw -xkb -listen 0.0.0.0 -ncache 10 -ncache_cr \
|
||||||
|
>/tmp/x11vnc.log 2>&1 &
|
||||||
|
sleep 0.3
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v websockify >/dev/null 2>&1; then
|
||||||
|
if ! pgrep -f "websockify.*${VNC_PORT}" >/dev/null 2>&1; then
|
||||||
|
echo "==> noVNC http://0.0.0.0:${VNC_PORT}/ (interactive Chrome for MFA)"
|
||||||
|
if [ -n "$NOVNC_WEB" ]; then
|
||||||
|
websockify --web="${NOVNC_WEB}" "${VNC_PORT}" "localhost:${RFB_PORT}" \
|
||||||
|
>/tmp/websockify.log 2>&1 &
|
||||||
|
else
|
||||||
|
websockify "${VNC_PORT}" "localhost:${RFB_PORT}" >/tmp/websockify.log 2>&1 &
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
start_vnc_stack
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
const DEFAULT_BACKEND = "http://localhost:8765";
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type === "SYNC_TO_BACKEND") {
|
||||||
|
syncSession(message.auth, message.backendUrl)
|
||||||
|
.then((result) => sendResponse(result))
|
||||||
|
.catch((err) => sendResponse({ ok: false, error: err.message }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
const AUTH_KEY = "pts_authdata";
|
||||||
|
|
||||||
|
function readPtsAuth() {
|
||||||
|
const raw = localStorage.getItem(AUTH_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type === "GET_PTS_AUTH") {
|
||||||
|
sendResponse({ ok: true, auth: readPtsAuth() });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "吉八小 · 憑證同步",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "把工時系統登入憑證同步到吉八小,集滿八小時",
|
||||||
|
"permissions": ["storage", "activeTab", "scripting"],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://tw-timesheet.supermicro.com/*",
|
||||||
|
"http://localhost:8765/*",
|
||||||
|
"http://127.0.0.1:8765/*"
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "吉八小 · 憑證同步"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://tw-timesheet.supermicro.com/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; width: 320px; margin: 0; padding: 16px; }
|
||||||
|
h1 { font-size: 16px; margin: 0 0 12px; }
|
||||||
|
label { display: block; font-size: 12px; margin-bottom: 4px; color: #555; }
|
||||||
|
input { width: 100%; padding: 8px; margin-bottom: 12px; box-sizing: border-box; }
|
||||||
|
button { width: 100%; padding: 10px; background: #2563eb; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }
|
||||||
|
#status { margin-top: 12px; font-size: 12px; white-space: pre-wrap; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>吉八小 · 憑證同步</h1>
|
||||||
|
<label for="backendUrl">後端網址</label>
|
||||||
|
<input id="backendUrl" type="text" value="http://localhost:8765" />
|
||||||
|
<button id="syncBtn">同步登入憑證到後端</button>
|
||||||
|
<div id="status"></div>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
const AUTH_KEY = "pts_authdata";
|
||||||
|
const statusEl = document.getElementById("status");
|
||||||
|
const backendInput = document.getElementById("backendUrl");
|
||||||
|
|
||||||
|
chrome.storage.local.get(["backendUrl"], (data) => {
|
||||||
|
if (data.backendUrl) backendInput.value = data.backendUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
function isPtsUrl(url) {
|
||||||
|
return !!url && url.includes("tw-timesheet.supermicro.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveTab() {
|
||||||
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuthViaMessage(tabId) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.tabs.sendMessage(tabId, { type: "GET_PTS_AUTH" }, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
resolve({
|
||||||
|
ok: false,
|
||||||
|
error: chrome.runtime.lastError.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response || { ok: false, error: "No response from content script" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAuthViaInjection(tabId) {
|
||||||
|
const results = await chrome.scripting.executeScript({
|
||||||
|
target: { tabId },
|
||||||
|
func: (key) => {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args: [AUTH_KEY],
|
||||||
|
});
|
||||||
|
const auth = results?.[0]?.result ?? null;
|
||||||
|
return { ok: true, auth };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAuthFromPage(tab) {
|
||||||
|
let result = await readAuthViaMessage(tab.id);
|
||||||
|
|
||||||
|
if (!result.ok || !result.auth) {
|
||||||
|
result = await readAuthViaInjection(tab.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSession(auth, backendUrl) {
|
||||||
|
if (!auth?.accessToken) {
|
||||||
|
throw new Error("找不到登入憑證,請確認已登入工時系統");
|
||||||
|
}
|
||||||
|
const res = await fetch(`${backendUrl}/api/session`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
accessToken: auth.accessToken,
|
||||||
|
refreshToken: auth.refreshToken,
|
||||||
|
groupCode: auth.groupCode,
|
||||||
|
raw: auth,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("syncBtn").addEventListener("click", async () => {
|
||||||
|
statusEl.textContent = "同步中...";
|
||||||
|
const backendUrl = backendInput.value.trim().replace(/\/$/, "");
|
||||||
|
chrome.storage.local.set({ backendUrl });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab?.id || !isPtsUrl(tab.url)) {
|
||||||
|
throw new Error("請先切到工時系統分頁(tw-timesheet.supermicro.com)再按同步");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageAuth = await readAuthFromPage(tab);
|
||||||
|
if (!pageAuth.auth) {
|
||||||
|
throw new Error(
|
||||||
|
pageAuth.error
|
||||||
|
? `${pageAuth.error}。請重新整理工時系統頁面後再試`
|
||||||
|
: "無法讀取登入憑證,請重新整理工時系統頁面並確認已登入"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await syncSession(pageAuth.auth, backendUrl);
|
||||||
|
statusEl.textContent = `成功:${result.message || "已同步"}`;
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = `失敗:${err.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
/**
|
||||||
|
* Home flow:
|
||||||
|
* - range mode: start~end, skip TW holidays/weekends
|
||||||
|
* - list mode: exact dates, fill even on holidays
|
||||||
|
* Preview/fill: Teams meetings + GitLab issues → PTS
|
||||||
|
*/
|
||||||
|
|
||||||
|
function setDefaultDates() {
|
||||||
|
const today = todayStr();
|
||||||
|
document.getElementById("startDate").value = today;
|
||||||
|
document.getElementById("endDate").value = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateMode() {
|
||||||
|
const checked = document.querySelector('input[name="dateMode"]:checked');
|
||||||
|
return checked ? checked.value : "range";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRange() {
|
||||||
|
const start = document.getElementById("startDate")?.value || todayStr();
|
||||||
|
let end = document.getElementById("endDate")?.value || start;
|
||||||
|
if (end < start) {
|
||||||
|
document.getElementById("endDate").value = start;
|
||||||
|
document.getElementById("startDate").value = end;
|
||||||
|
return { start: end, end: start };
|
||||||
|
}
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateListText() {
|
||||||
|
return (document.getElementById("dateList")?.value || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateList(text) {
|
||||||
|
const dates = [];
|
||||||
|
for (const line of text.split(/\n+/)) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t || t.startsWith("#")) continue;
|
||||||
|
for (const part of t.split(/[\s,;]+/)) {
|
||||||
|
const d = part.trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(d)) dates.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...new Set(dates)].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRangeToToday() {
|
||||||
|
const today = todayStr();
|
||||||
|
document.getElementById("startDate").value = today;
|
||||||
|
document.getElementById("endDate").value = today;
|
||||||
|
updateRangeMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDateModeUI() {
|
||||||
|
const mode = getDateMode();
|
||||||
|
document.getElementById("rangePanel")?.classList.toggle("hidden", mode !== "range");
|
||||||
|
document.getElementById("listPanel")?.classList.toggle("hidden", mode !== "list");
|
||||||
|
updateRangeMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRangeMeta() {
|
||||||
|
const el = document.getElementById("rangeMeta");
|
||||||
|
if (!el) return;
|
||||||
|
if (getDateMode() === "list") {
|
||||||
|
const dates = parseDateList(getDateListText());
|
||||||
|
el.textContent = dates.length
|
||||||
|
? `指定 ${dates.length} 天(含假日也填):${dates.join("、")}`
|
||||||
|
: "請輸入至少一個日期";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { start, end } = getDateRange();
|
||||||
|
el.textContent = `區間 ${start} → ${end}(自動跳過週末/台灣假日)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActionStatus(text, ok) {
|
||||||
|
const el = document.getElementById("actionStatus");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "settings-status" + (ok === true ? " ok" : ok === false ? " err" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConn() {
|
||||||
|
const line = document.getElementById("connLine");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/status");
|
||||||
|
const teams = data.teams_calendar || {};
|
||||||
|
const parts = [
|
||||||
|
data.has_pts_session ? "工時 ✓" : "工時 ✗",
|
||||||
|
data.gitlab_configured && !data.gitlab_error ? "任務 ✓" : "任務 ✗",
|
||||||
|
data.teams_calendar_enabled ? (teams.connected ? "行事曆 ✓" : "行事曆 ✗") : "行事曆 關",
|
||||||
|
];
|
||||||
|
if (line) {
|
||||||
|
line.textContent = parts.join(" · ");
|
||||||
|
if (data.gitlab_error) line.textContent += " — " + data.gitlab_error;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (line) line.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan(data, { filled } = {}) {
|
||||||
|
const root = document.getElementById("planView");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const workdays = data.workdays || [];
|
||||||
|
const plan = data.plan || {};
|
||||||
|
const meetings = data.analysis?.meetings || data.meetings || {};
|
||||||
|
const source = data.source || "";
|
||||||
|
const gitlabN = data.gitlab_task_count ?? "—";
|
||||||
|
|
||||||
|
const meetingNote =
|
||||||
|
meetings.enabled === false
|
||||||
|
? "會議未啟用"
|
||||||
|
: meetings.error
|
||||||
|
? `會議:${meetings.error}`
|
||||||
|
: meetings.meeting_count != null
|
||||||
|
? `會議 ${meetings.meeting_count} 場 · ${meetings.meeting_hours_total ?? 0}h`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
if (!workdays.length && !Object.keys(plan).length) {
|
||||||
|
root.innerHTML = `<p class="hint">沒有可填日期。區間模式會跳過假日;指定多日請確認日期格式。</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = workdays.length ? workdays : Object.keys(plan).sort();
|
||||||
|
const dayBlocks = days
|
||||||
|
.map((day) => {
|
||||||
|
const entries = plan[day] || [];
|
||||||
|
if (!entries.length) {
|
||||||
|
return `<div class="plan-day">
|
||||||
|
<div class="plan-day-head"><strong>${escapeHtml(day)}</strong><span class="muted-inline">無項目</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
const hours = entries.reduce((s, e) => s + Number(e.take_hours || 0), 0);
|
||||||
|
const rows = entries
|
||||||
|
.map((e) => {
|
||||||
|
const desc = String(e.description || "");
|
||||||
|
const isMeeting =
|
||||||
|
(e.pts_task_name || "").toLowerCase().includes("meet") ||
|
||||||
|
desc.toLowerCase().includes("meeting") ||
|
||||||
|
desc.toLowerCase().includes("attended");
|
||||||
|
const tag = isMeeting
|
||||||
|
? '<span class="tag">會議</span>'
|
||||||
|
: e.gitlab_task_id
|
||||||
|
? '<span class="tag">GitLab</span>'
|
||||||
|
: "";
|
||||||
|
return `<li class="plan-row">
|
||||||
|
<span class="meeting-hours">${escapeHtml(String(e.take_hours))}h</span>
|
||||||
|
<span class="meeting-subject">${tag}${escapeHtml(e.pts_task_name || "")} · ${escapeHtml(desc)}</span>
|
||||||
|
</li>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<div class="plan-day">
|
||||||
|
<div class="plan-day-head">
|
||||||
|
<strong>${escapeHtml(day)}</strong>
|
||||||
|
<span class="muted-inline">${entries.length} 筆 · ${hours}h</span>
|
||||||
|
</div>
|
||||||
|
<ul class="meeting-items">${rows}</ul>
|
||||||
|
</div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const title = filled ? "已填寫" : "預覽";
|
||||||
|
const dry = data.dry_run ? "(試跑)" : "";
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="meetings-summary">
|
||||||
|
${pill(title + dry, source || "ok", true)}
|
||||||
|
${pill("天數", String(days.length), null)}
|
||||||
|
${pill("GitLab", String(gitlabN), null)}
|
||||||
|
${meetingNote ? pill("Teams", meetingNote, meetings.error ? false : null) : ""}
|
||||||
|
</div>
|
||||||
|
<div class="meetings-list">${dayBlocks}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPreview() {
|
||||||
|
const mode = getDateMode();
|
||||||
|
setActionStatus("預覽中…");
|
||||||
|
document.getElementById("planView").innerHTML = `<p class="hint">會議 + GitLab 分配中…</p>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (mode === "list") {
|
||||||
|
const dates = parseDateList(getDateListText());
|
||||||
|
if (!dates.length) throw new Error("請至少輸入一個日期(YYYY-MM-DD)");
|
||||||
|
data = await api("/api/preview/dates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ dates_text: dates.join("\n") }),
|
||||||
|
});
|
||||||
|
document.getElementById("rangeMeta").textContent =
|
||||||
|
`指定 ${dates.length} 天(含假日):${dates.join("、")}`;
|
||||||
|
} else {
|
||||||
|
const { start, end } = getDateRange();
|
||||||
|
data = await api(
|
||||||
|
`/api/preview?start_date=${encodeURIComponent(start)}&end_date=${encodeURIComponent(end)}`
|
||||||
|
);
|
||||||
|
document.getElementById("rangeMeta").textContent =
|
||||||
|
`區間 ${start} → ${end} · 工作日 ${(data.workdays || []).length} 天(已跳過假日)`;
|
||||||
|
}
|
||||||
|
renderPlan(data, { filled: false });
|
||||||
|
setActionStatus("預覽完成(尚未送出)", true);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("planView").innerHTML =
|
||||||
|
`<p class="settings-status err">${escapeHtml(err.message)}</p>`;
|
||||||
|
setActionStatus(err.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFill() {
|
||||||
|
const mode = getDateMode();
|
||||||
|
const dryRun = document.getElementById("dryRun").checked;
|
||||||
|
setActionStatus(`${dryRun ? "試跑" : "填寫"}中…`);
|
||||||
|
document.getElementById("planView").innerHTML = `<p class="hint">處理中…</p>`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data;
|
||||||
|
if (mode === "list") {
|
||||||
|
const dates = parseDateList(getDateListText());
|
||||||
|
if (!dates.length) throw new Error("請至少輸入一個日期(YYYY-MM-DD)");
|
||||||
|
data = await api("/api/fill/dates", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
dates_text: dates.join("\n"),
|
||||||
|
dry_run: dryRun,
|
||||||
|
skip_existing: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
document.getElementById("rangeMeta").textContent =
|
||||||
|
`指定 ${dates.length} 天` + (dryRun ? " · 試跑" : " · 已送出");
|
||||||
|
} else {
|
||||||
|
const { start, end } = getDateRange();
|
||||||
|
data = await api("/api/fill/range", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
start_date: start,
|
||||||
|
end_date: end,
|
||||||
|
dry_run: dryRun,
|
||||||
|
skip_existing: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
document.getElementById("rangeMeta").textContent =
|
||||||
|
`區間 ${start} → ${end}` + (dryRun ? " · 試跑" : " · 已送出");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.plan || data.workdays) {
|
||||||
|
renderPlan({ ...data, dry_run: dryRun }, { filled: !dryRun });
|
||||||
|
} else {
|
||||||
|
document.getElementById("planView").innerHTML = `
|
||||||
|
<div class="meetings-summary">
|
||||||
|
${pill(dryRun ? "試跑完成" : "填寫完成", "ok", true)}
|
||||||
|
${pill("新增", String((data.created || []).length), null)}
|
||||||
|
${pill("略過", String((data.skipped || []).length), null)}
|
||||||
|
</div>
|
||||||
|
<pre class="preview">${escapeHtml(JSON.stringify(data, null, 2))}</pre>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
setActionStatus(dryRun ? "試跑完成" : "填寫完成", true);
|
||||||
|
await refreshConn();
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("planView").innerHTML =
|
||||||
|
`<p class="settings-status err">${escapeHtml(err.message)}</p>`;
|
||||||
|
setActionStatus(err.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('input[name="dateMode"]').forEach((el) => {
|
||||||
|
el.addEventListener("change", updateDateModeUI);
|
||||||
|
});
|
||||||
|
document.getElementById("setTodayBtn")?.addEventListener("click", setRangeToToday);
|
||||||
|
document.getElementById("previewBtn")?.addEventListener("click", () => runPreview().catch(console.error));
|
||||||
|
document.getElementById("fillBtn")?.addEventListener("click", () => runFill().catch(console.error));
|
||||||
|
document.getElementById("startDate")?.addEventListener("change", updateRangeMeta);
|
||||||
|
document.getElementById("endDate")?.addEventListener("change", updateRangeMeta);
|
||||||
|
document.getElementById("dateList")?.addEventListener("input", updateRangeMeta);
|
||||||
|
|
||||||
|
setDefaultDates();
|
||||||
|
updateDateModeUI();
|
||||||
|
refreshConn().catch(console.error);
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
const API = "";
|
||||||
|
|
||||||
|
const THEME_KEY = "pts_theme";
|
||||||
|
|
||||||
|
function resolveTheme(pref) {
|
||||||
|
if (pref === "light" || pref === "dark") return pref;
|
||||||
|
// system
|
||||||
|
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemePref() {
|
||||||
|
return localStorage.getItem(THEME_KEY) || "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(pref) {
|
||||||
|
const resolved = resolveTheme(pref);
|
||||||
|
document.documentElement.setAttribute("data-theme", resolved);
|
||||||
|
document.documentElement.dataset.themePref = pref;
|
||||||
|
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||||
|
document.querySelectorAll("[data-theme-option]").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.getAttribute("data-theme-option") === pref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThemePref(pref) {
|
||||||
|
localStorage.setItem(THEME_KEY, pref);
|
||||||
|
applyTheme(pref);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTheme() {
|
||||||
|
applyTheme(getThemePref());
|
||||||
|
// react to OS changes when using system
|
||||||
|
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => {
|
||||||
|
if (getThemePref() === "system") applyTheme("system");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-theme-option]").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
setThemePref(btn.getAttribute("data-theme-option"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(`${API}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = data.detail;
|
||||||
|
const message =
|
||||||
|
typeof detail === "string"
|
||||||
|
? detail
|
||||||
|
: Array.isArray(detail)
|
||||||
|
? detail.map((d) => d.msg || JSON.stringify(d)).join("; ")
|
||||||
|
: res.statusText;
|
||||||
|
throw new Error(message || res.statusText);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pill(label, value, ok) {
|
||||||
|
const cls = ok === true ? "ok" : ok === false ? "warn" : "";
|
||||||
|
return `<span class="pill ${cls}"><span class="pill-label">${label}</span><span class="pill-value">${value}</span></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply ASAP if script loads after head bootstrap
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", initTheme);
|
||||||
|
} else {
|
||||||
|
initTheme();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>刷卡補填 · 吉八小</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://font.emtech.cc" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://font.emtech.cc/css/TaipeiSansTC.css" rel="stylesheet" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var p = localStorage.getItem("pts_theme") || "system";
|
||||||
|
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
var t = p === "light" || p === "dark" ? p : dark ? "dark" : "light";
|
||||||
|
document.documentElement.setAttribute("data-theme", t);
|
||||||
|
document.documentElement.classList.toggle("dark", t === "dark");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container narrow">
|
||||||
|
<header class="topbar compact">
|
||||||
|
<div>
|
||||||
|
<h1>刷卡補填</h1>
|
||||||
|
<p class="subtitle" id="connLine">檢查 CompBase 連線…</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="theme-toggle" title="外觀">
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="light">淺</button>
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="dark">深</button>
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="system">系統</button>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-link">填工時</a>
|
||||||
|
<a href="/compbase" class="nav-link active">刷卡補填</a>
|
||||||
|
<a href="/settings" class="nav-link">設定</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>一鍵補漏刷退</h2>
|
||||||
|
<p class="hint">
|
||||||
|
登入 CompBase → 出勤檢視 → 找出「應刷未刷」→ 自動選正常下班時間送出。
|
||||||
|
與 PTS 工時無關;預設使用設定裡的 PTS 帳密做 NTLM。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
近 N 天
|
||||||
|
<input type="number" id="days" min="1" max="62" value="14" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
刷退時間
|
||||||
|
<select id="outTimeMode">
|
||||||
|
<option value="expected">正常工時下班(推薦)</option>
|
||||||
|
<option value="latest_option">下拉最後一個可用時間</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="checkbox dry-run-row">
|
||||||
|
<input type="checkbox" id="dryRun" checked />
|
||||||
|
試跑(只掃描/預覽,不送出)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="scanBtn" class="btn secondary">掃描</button>
|
||||||
|
<button type="button" id="fillBtn" class="btn primary">一鍵補填</button>
|
||||||
|
</div>
|
||||||
|
<p id="actionStatus" class="settings-status"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>結果</h2>
|
||||||
|
<div id="resultView" class="plan-view">
|
||||||
|
<p class="hint">選天數後按「掃描」或「一鍵補填」。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<script src="/static/common.js"></script>
|
||||||
|
<script src="/static/compbase.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/**
|
||||||
|
* CompBase attendance: scan missing clock-out (應刷未刷) and auto-fill via RID page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function setActionStatus(text, ok) {
|
||||||
|
const el = document.getElementById("actionStatus");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "settings-status" + (ok === true ? " ok" : ok === false ? " err" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDays() {
|
||||||
|
const n = Number(document.getElementById("days")?.value || 14);
|
||||||
|
if (!Number.isFinite(n) || n < 1) return 14;
|
||||||
|
return Math.min(62, Math.floor(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOutTimeMode() {
|
||||||
|
return document.getElementById("outTimeMode")?.value || "expected";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDryRun() {
|
||||||
|
return Boolean(document.getElementById("dryRun")?.checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshConn() {
|
||||||
|
const line = document.getElementById("connLine");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/compbase/status");
|
||||||
|
if (data.default_days && document.getElementById("days")) {
|
||||||
|
document.getElementById("days").value = data.default_days;
|
||||||
|
}
|
||||||
|
if (data.out_time_mode && document.getElementById("outTimeMode")) {
|
||||||
|
document.getElementById("outTimeMode").value = data.out_time_mode;
|
||||||
|
}
|
||||||
|
if (!line) return;
|
||||||
|
if (!data.has_credentials) {
|
||||||
|
line.textContent = "未設定帳密(請到設定登入 PTS,或填 CompBase 帳密)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = data.display_name || data.username || "—";
|
||||||
|
const emp = data.emp_id ? ` · ${data.emp_id}` : "";
|
||||||
|
const src = data.credential_source === "compbase" ? "CompBase 帳密" : "PTS 帳密";
|
||||||
|
line.textContent = data.connected
|
||||||
|
? `CompBase ✓ · ${name}${emp} · ${src}`
|
||||||
|
: `CompBase ✗ · ${data.error || "連線失敗"} · ${src}`;
|
||||||
|
} catch (err) {
|
||||||
|
if (line) line.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowStatusLabel(row, kind) {
|
||||||
|
if (kind === "filled") {
|
||||||
|
if (row.dry_run) return `試跑 · 將送 ${escapeHtml(row.chosen_out_time || "—")}`;
|
||||||
|
return `已送出 · ${escapeHtml(row.chosen_out_time || "—")}${row.message ? " · " + escapeHtml(row.message) : ""}`;
|
||||||
|
}
|
||||||
|
if (kind === "error") {
|
||||||
|
return `失敗 · ${escapeHtml(row.error || row.message || "unknown")}`;
|
||||||
|
}
|
||||||
|
const reason = row.reason || row.status || "skipped";
|
||||||
|
const map = {
|
||||||
|
no_rid: "無 RID(可能缺刷進)",
|
||||||
|
missing_in: "缺刷進",
|
||||||
|
missing_out: "缺刷退但無連結",
|
||||||
|
other: "其他異常",
|
||||||
|
};
|
||||||
|
return map[reason] || escapeHtml(String(reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(data, mode) {
|
||||||
|
const root = document.getElementById("resultView");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const filled = data.filled || data.fillable || [];
|
||||||
|
const skipped = data.skipped || [];
|
||||||
|
const errors = data.errors || [];
|
||||||
|
const isScan = mode === "scan";
|
||||||
|
|
||||||
|
const summaryParts = [
|
||||||
|
`區間 ${escapeHtml(data.start || "—")} → ${escapeHtml(data.end || "—")}`,
|
||||||
|
isScan
|
||||||
|
? `可補 ${data.fillable_count ?? filled.length}`
|
||||||
|
: `完成 ${data.filled_count ?? filled.length}`,
|
||||||
|
`略過 ${data.skipped_count ?? skipped.length}`,
|
||||||
|
];
|
||||||
|
if (!isScan) {
|
||||||
|
summaryParts.push(`失敗 ${data.error_count ?? errors.length}`);
|
||||||
|
if (data.dry_run) summaryParts.push("試跑");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = [];
|
||||||
|
blocks.push(`<p class="muted-inline">${summaryParts.join(" · ")}</p>`);
|
||||||
|
|
||||||
|
function table(title, rows, kind) {
|
||||||
|
if (!rows.length) return "";
|
||||||
|
const body = rows
|
||||||
|
.map((r) => {
|
||||||
|
return `<tr>
|
||||||
|
<td>${escapeHtml(r.date || "—")}</td>
|
||||||
|
<td><code>${escapeHtml(r.rid || "—")}</code></td>
|
||||||
|
<td>${escapeHtml(r.actual_in || r.in_time || "—")}</td>
|
||||||
|
<td>${escapeHtml(r.exp_out || "—")}</td>
|
||||||
|
<td>${rowStatusLabel(r, kind)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<div class="plan-day">
|
||||||
|
<div class="plan-day-head"><strong>${escapeHtml(title)}</strong><span class="muted-inline">${rows.length}</span></div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>日期</th><th>RID</th><th>刷進</th><th>應下班</th><th>狀態</th></tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isScan) {
|
||||||
|
blocks.push(table("可補填(有 RID)", filled, "filled"));
|
||||||
|
blocks.push(table("略過", skipped, "skipped"));
|
||||||
|
} else {
|
||||||
|
blocks.push(table(data.dry_run ? "試跑將送出" : "已送出", filled, "filled"));
|
||||||
|
blocks.push(table("略過", skipped, "skipped"));
|
||||||
|
blocks.push(table("失敗", errors, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 1) {
|
||||||
|
blocks.push(`<p class="hint">近 ${escapeHtml(String(data.days || ""))} 天沒有可處理的漏刷退。</p>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
root.innerHTML = blocks.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScan() {
|
||||||
|
const days = getDays();
|
||||||
|
setActionStatus(`掃描近 ${days} 天…`);
|
||||||
|
try {
|
||||||
|
const data = await api("/api/compbase/scan", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ days }),
|
||||||
|
});
|
||||||
|
renderResults(data, "scan");
|
||||||
|
setActionStatus(
|
||||||
|
`掃描完成:可補 ${data.fillable_count ?? 0}、略過 ${data.skipped_count ?? 0}`,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setActionStatus(err.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFill() {
|
||||||
|
const days = getDays();
|
||||||
|
const dryRun = isDryRun();
|
||||||
|
const outTimeMode = getOutTimeMode();
|
||||||
|
if (!dryRun) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`將對近 ${days} 天的「應刷未刷」送出刷退補填到 CompBase。\n確定繼續?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
setActionStatus(dryRun ? `試跑近 ${days} 天…` : `補填近 ${days} 天…`);
|
||||||
|
try {
|
||||||
|
const data = await api("/api/compbase/fill", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
days,
|
||||||
|
dry_run: dryRun,
|
||||||
|
out_time_mode: outTimeMode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
renderResults(data, "fill");
|
||||||
|
const ok = (data.error_count ?? 0) === 0;
|
||||||
|
setActionStatus(
|
||||||
|
`${dryRun ? "試跑" : "補填"}完成:成功 ${data.filled_count ?? 0}、略過 ${data.skipped_count ?? 0}、失敗 ${data.error_count ?? 0}`,
|
||||||
|
ok
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setActionStatus(err.message, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
document.getElementById("scanBtn")?.addEventListener("click", runScan);
|
||||||
|
document.getElementById("fillBtn")?.addEventListener("click", runFill);
|
||||||
|
refreshConn();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>吉八小 · 集滿八小時</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://font.emtech.cc" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://font.emtech.cc/css/TaipeiSansTC.css" rel="stylesheet" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var p = localStorage.getItem("pts_theme") || "system";
|
||||||
|
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
var t = p === "light" || p === "dark" ? p : dark ? "dark" : "light";
|
||||||
|
document.documentElement.setAttribute("data-theme", t);
|
||||||
|
document.documentElement.classList.toggle("dark", t === "dark");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container narrow">
|
||||||
|
<header class="topbar compact">
|
||||||
|
<div>
|
||||||
|
<h1>吉八小</h1>
|
||||||
|
<p class="subtitle">集滿八小時 · <span id="connLine">檢查連線…</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="theme-toggle" title="外觀">
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="light">淺</button>
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="dark">深</button>
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="system">系統</button>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-link active">填工時</a>
|
||||||
|
<a href="/compbase" class="nav-link">刷卡補填</a>
|
||||||
|
<a href="/settings" class="nav-link">設定</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>1. 選日期</h2>
|
||||||
|
|
||||||
|
<div class="mode-row">
|
||||||
|
<label class="mode-option">
|
||||||
|
<input type="radio" name="dateMode" value="range" checked />
|
||||||
|
<span>日期區間</span>
|
||||||
|
<small>自動跳過週末/台灣假日</small>
|
||||||
|
</label>
|
||||||
|
<label class="mode-option">
|
||||||
|
<input type="radio" name="dateMode" value="list" />
|
||||||
|
<span>指定多日</span>
|
||||||
|
<small>照填,不跳假日</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="rangePanel" class="date-panel">
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
開始
|
||||||
|
<input type="date" id="startDate" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
結束
|
||||||
|
<input type="date" id="endDate" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions tight">
|
||||||
|
<button type="button" id="setTodayBtn" class="btn secondary">今天</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="listPanel" class="date-panel hidden">
|
||||||
|
<label>
|
||||||
|
日期清單(一行一個,YYYY-MM-DD)
|
||||||
|
<textarea id="dateList" class="date-list" rows="5" placeholder="2026-07-12 2026-07-13 2026-07-19"></textarea>
|
||||||
|
</label>
|
||||||
|
<p class="hint small">無論週末或台灣假日都會填這些日期。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="rangeMeta" class="muted-inline">—</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>2. 預覽 / 填寫</h2>
|
||||||
|
<p class="hint">Teams 會議 + GitLab issue → 分配時數 → 寫入 PTS。</p>
|
||||||
|
<label class="checkbox dry-run-row">
|
||||||
|
<input type="checkbox" id="dryRun" />
|
||||||
|
試跑(不真的送出)
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="previewBtn" class="btn secondary">預覽</button>
|
||||||
|
<button type="button" id="fillBtn" class="btn primary">填寫</button>
|
||||||
|
</div>
|
||||||
|
<p id="actionStatus" class="settings-status"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>CompBase 刷卡補填</h2>
|
||||||
|
<p class="hint">掃出勤「應刷未刷」、一鍵補刷退(與上方 PTS 工時無關)。</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/compbase" class="btn primary">前往刷卡補填</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>3. 結果</h2>
|
||||||
|
<div id="planView" class="plan-view">
|
||||||
|
<p class="hint">選好日期後按「預覽」或「填寫」。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<script src="/static/common.js"></script>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>設定 · 吉八小</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="preconnect" href="https://font.emtech.cc" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://font.emtech.cc/css/TaipeiSansTC.css" rel="stylesheet" />
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var p = localStorage.getItem("pts_theme") || "system";
|
||||||
|
var dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
var t = p === "light" || p === "dark" ? p : dark ? "dark" : "light";
|
||||||
|
document.documentElement.setAttribute("data-theme", t);
|
||||||
|
document.documentElement.classList.toggle("dark", t === "dark");
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>設定</h1>
|
||||||
|
<p class="subtitle">吉八小 · 帳號連線與系統參數</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<div class="theme-toggle" title="外觀">
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="light">淺色</button>
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="dark">深色</button>
|
||||||
|
<button type="button" class="theme-btn" data-theme-option="system">系統</button>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-link">填工時</a>
|
||||||
|
<a href="/compbase" class="nav-link">刷卡補填</a>
|
||||||
|
<a href="/settings" class="nav-link active">設定</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>帳號連線</h2>
|
||||||
|
<p class="hint">輸入帳密登入。能打 API 就直接打;需 MFA 時才開瀏覽器。密碼加密存本機。</p>
|
||||||
|
|
||||||
|
<div class="auth-grid">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>PTS</h3>
|
||||||
|
<div id="ptsAuthStatus" class="badge warn">檢查中…</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label>帳號<input type="text" id="ptsUsername" placeholder="DOMAIN\user" autocomplete="username" /></label>
|
||||||
|
<label>密碼<input type="password" id="ptsPassword" placeholder="密碼" autocomplete="current-password" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="ptsLoginBtn" class="btn primary">登入</button>
|
||||||
|
<button type="button" id="ptsLogoutBtn" class="btn secondary">清除 Session</button>
|
||||||
|
</div>
|
||||||
|
<p id="ptsLoginMsg" class="settings-status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Teams 行事曆</h3>
|
||||||
|
<div id="teamsAuthStatus" class="badge">選配</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="checkbox-row"><input type="checkbox" id="teamsEnabled" /> 啟用會議併入工時</label>
|
||||||
|
<label>Microsoft 帳號<input type="text" id="teamsUsername" placeholder="you@company.com" autocomplete="username" /></label>
|
||||||
|
<label>密碼<input type="password" id="teamsPassword" placeholder="密碼" autocomplete="current-password" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="teamsLoginBtn" class="btn primary">登入</button>
|
||||||
|
<button type="button" id="teamsLogoutBtn" class="btn secondary">中斷</button>
|
||||||
|
</div>
|
||||||
|
<p id="teamsLoginMsg" class="settings-status"></p>
|
||||||
|
<p class="hint small">Docker:登入會開虛擬 Chrome,可用互動畫面完成 MFA(最多約 10 分鐘,不會中途關掉)。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Teams MFA:noVNC 互動 + 截圖預覽 -->
|
||||||
|
<div id="teamsMfaModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="teamsMfaTitle">
|
||||||
|
<div class="modal-card modal-wide">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="teamsMfaTitle">Teams 驗證(MFA)</h2>
|
||||||
|
<button type="button" id="teamsMfaClose" class="btn secondary btn-sm">關閉預覽</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="teamsMfaHint">
|
||||||
|
帳密送出後,請在下方<strong>互動畫面</strong>完成驗證(可點數字、輸入驗證碼)。
|
||||||
|
瀏覽器會保持開啟直到登入成功或逾時,請勿急著關掉。
|
||||||
|
</p>
|
||||||
|
<p class="settings-status" id="teamsMfaStatus">準備中…</p>
|
||||||
|
<div class="mfa-grid">
|
||||||
|
<div class="mfa-panel">
|
||||||
|
<div class="mfa-panel-head">
|
||||||
|
<strong>互動畫面</strong>
|
||||||
|
<a id="teamsVncLink" class="linkish" href="#" target="_blank" rel="noopener">新分頁開啟</a>
|
||||||
|
</div>
|
||||||
|
<iframe id="teamsVncFrame" class="mfa-frame" title="Teams 瀏覽器互動畫面" allow="clipboard-read; clipboard-write"></iframe>
|
||||||
|
<p class="hint small">若 iframe 空白,請用「新分頁開啟」。埠預設 6080。</p>
|
||||||
|
</div>
|
||||||
|
<div class="mfa-panel">
|
||||||
|
<div class="mfa-panel-head">
|
||||||
|
<strong>即時截圖</strong>
|
||||||
|
<span class="muted-inline" id="teamsMfaShotMeta">—</span>
|
||||||
|
</div>
|
||||||
|
<img id="teamsMfaShot" class="mfa-shot" alt="MFA 截圖預覽" />
|
||||||
|
<p class="hint small">約每 1.5 秒更新,方便對號/看驗證畫面。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>測試:Teams 抓會議</h2>
|
||||||
|
<p class="hint">只測行事曆爬蟲(不走 Graph API)。先「登入 Teams」完成 MFA,再選日期測試。不寫 PTS、不碰 GitLab。</p>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
開始
|
||||||
|
<input type="date" id="teamsTestStart" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
結束
|
||||||
|
<input type="date" id="teamsTestEnd" />
|
||||||
|
</label>
|
||||||
|
<label class="checkbox dry-run-row">
|
||||||
|
<input type="checkbox" id="teamsTestSkipHolidays" checked />
|
||||||
|
跳過假日
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="teamsTestBtn" class="btn primary">測試抓會議</button>
|
||||||
|
<button type="button" id="teamsStatusBtn" class="btn secondary">連線狀態</button>
|
||||||
|
</div>
|
||||||
|
<p id="teamsTestMsg" class="settings-status"></p>
|
||||||
|
<div id="teamsTestSummary" class="meetings-summary"></div>
|
||||||
|
<div id="teamsTestList" class="meetings-list"></div>
|
||||||
|
<pre id="teamsTestDebug" class="preview">尚無測試結果</pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>系統參數</h2>
|
||||||
|
<form id="settingsForm" class="settings-form">
|
||||||
|
<fieldset>
|
||||||
|
<legend>GitLab</legend>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label>URL<input type="url" name="gitlab_url" /></label>
|
||||||
|
<label>GitLab Personal Access Token
|
||||||
|
<input type="password" name="gitlab_token" placeholder="貼上 token" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<p id="gitlabTokenHint" class="hint small full-span"></p>
|
||||||
|
<label>Project Path<input type="text" name="gitlab_project_path" /></label>
|
||||||
|
<label>Board ID<input type="number" name="gitlab_board_id" min="1" /></label>
|
||||||
|
<label>Board Labels<input type="text" name="gitlab_board_labels" /></label>
|
||||||
|
<label>Milestone<input type="text" name="gitlab_milestone_title" placeholder="留空自動" /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>工時填寫</legend>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label>PTS URL<input type="url" name="pts_url" /></label>
|
||||||
|
<label>Project Name<input type="text" name="pts_project_name" /></label>
|
||||||
|
<label>Task Type<input type="text" name="pts_default_task_name" /></label>
|
||||||
|
<label>Max Hours / Day<input type="number" name="max_hours_per_day" min="0.5" max="24" step="0.5" /></label>
|
||||||
|
<label>Hour Step<input type="number" name="hour_step" min="0.5" max="8" step="0.5" /></label>
|
||||||
|
<label>時區<input type="text" name="teams_calendar_timezone" /></label>
|
||||||
|
<label>會議 Task 預設<input type="text" name="teams_meeting_task_name" /></label>
|
||||||
|
<label class="full-span">排除加入 PTS 的會議關鍵字(逗號分隔,符合的會議仍會顯示在下方列表,但不會寫入 PTS)
|
||||||
|
<input type="text" name="teams_meeting_exclude_keywords" placeholder="例如:貼心的提醒,請記得下班刷退" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>CompBase 刷卡補填(選填)</legend>
|
||||||
|
<p class="hint small full-span">預設沿用上方 PTS 帳密做 NTLM。僅在帳號不同時才填這裡。</p>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label>List URL<input type="url" name="compbase_list_url" /></label>
|
||||||
|
<label>Fill URL<input type="url" name="compbase_fill_url" /></label>
|
||||||
|
<label>帳號<input type="text" name="compbase_username" placeholder="留空=用工時帳號" autocomplete="username" /></label>
|
||||||
|
<label>密碼<input type="password" name="compbase_password" placeholder="留空不變更/用工時密碼" autocomplete="new-password" /></label>
|
||||||
|
<label>預設近 N 天<input type="number" name="compbase_default_days" min="1" max="62" /></label>
|
||||||
|
<label>刷退模式
|
||||||
|
<select name="compbase_out_time_mode">
|
||||||
|
<option value="expected">expected(正常工時)</option>
|
||||||
|
<option value="latest_option">latest_option</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Grok LLM</legend>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="checkbox-row"><input type="checkbox" name="use_llm" /> 啟用 Grok</label>
|
||||||
|
<label class="checkbox-row"><input type="checkbox" name="use_description_variation" /> 描述變化</label>
|
||||||
|
<label>API Key<input type="password" name="xai_api_key" placeholder="留空不變更" autocomplete="off" /></label>
|
||||||
|
<label>Base URL<input type="url" name="xai_base_url" /></label>
|
||||||
|
<label>Model<input type="text" name="grok_model" /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>自動排程</legend>
|
||||||
|
<div class="settings-grid">
|
||||||
|
<label class="checkbox-row"><input type="checkbox" name="auto_fill_enabled" /> 每日自動填寫</label>
|
||||||
|
<label>Hour<input type="number" name="auto_fill_hour" min="0" max="23" /></label>
|
||||||
|
<label>Minute<input type="number" name="auto_fill_minute" min="0" max="59" /></label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>補寫日期 (dates.txt)</legend>
|
||||||
|
<textarea id="datesFileContent" name="dates_file_content" class="date-list" rows="6" placeholder="一行一個日期"></textarea>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="btn primary">儲存設定</button>
|
||||||
|
<a href="/" class="btn secondary">回主頁</a>
|
||||||
|
<span id="settingsStatus" class="settings-status"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card muted-card">
|
||||||
|
<h2>備援:瀏覽器擴充功能</h2>
|
||||||
|
<p class="hint">帳密登入不可用時,可用擴充功能同步工時系統登入憑證。</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/api/extension/download" class="btn secondary" download="jibaxiao-sync.zip">下載擴充功能</a>
|
||||||
|
</div>
|
||||||
|
<ol class="install-steps">
|
||||||
|
<li>解壓後於 <code>chrome://extensions</code> 載入未封裝項目</li>
|
||||||
|
<li>登入工時系統網頁 → 重新整理 → 擴充功能同步憑證</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<script src="/static/common.js"></script>
|
||||||
|
<script src="/static/settings.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,485 @@
|
||||||
|
const BOOL_FIELDS = [
|
||||||
|
"use_llm",
|
||||||
|
"use_description_variation",
|
||||||
|
"auto_fill_enabled",
|
||||||
|
];
|
||||||
|
const NUMBER_FIELDS = [
|
||||||
|
"gitlab_board_id",
|
||||||
|
"auto_fill_hour",
|
||||||
|
"auto_fill_minute",
|
||||||
|
"max_hours_per_day",
|
||||||
|
"hour_step",
|
||||||
|
"compbase_default_days",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECRET_INPUTS = new Set(["gitlab_token", "xai_api_key", "compbase_password"]);
|
||||||
|
|
||||||
|
function fillSettingsForm(data) {
|
||||||
|
const form = document.getElementById("settingsForm");
|
||||||
|
for (const el of form.querySelectorAll("[name]")) {
|
||||||
|
const name = el.name;
|
||||||
|
if (name === "dates_file_content") continue;
|
||||||
|
if (BOOL_FIELDS.includes(name)) {
|
||||||
|
el.checked = Boolean(data[name]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Never put masked secrets back into password fields (looks like a real token)
|
||||||
|
if (SECRET_INPUTS.has(name)) {
|
||||||
|
el.value = "";
|
||||||
|
el.placeholder = data[`${name}_set`]
|
||||||
|
? "已設定(重新貼上才會更新)"
|
||||||
|
: "貼上 token";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (data[name] !== undefined && data[name] !== null) {
|
||||||
|
el.value = data[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.pts_username) {
|
||||||
|
document.getElementById("ptsUsername").value = data.pts_username;
|
||||||
|
}
|
||||||
|
if (data.teams_username) {
|
||||||
|
document.getElementById("teamsUsername").value = data.teams_username;
|
||||||
|
}
|
||||||
|
document.getElementById("teamsEnabled").checked = Boolean(data.teams_calendar_enabled);
|
||||||
|
|
||||||
|
const gitlabHint = document.getElementById("gitlabTokenHint");
|
||||||
|
if (gitlabHint) {
|
||||||
|
gitlabHint.textContent = data.gitlab_token_set
|
||||||
|
? "Token 已儲存。若無法使用請重新產生並貼上新的 Personal Access Token(權限至少 api)。"
|
||||||
|
: "到 GitLab → Preferences → Access Tokens 建立,勾選 api。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSettingsPayload(form) {
|
||||||
|
const payload = {};
|
||||||
|
for (const el of form.querySelectorAll("[name]")) {
|
||||||
|
const name = el.name;
|
||||||
|
if (name === "dates_file_content") continue;
|
||||||
|
if (BOOL_FIELDS.includes(name)) {
|
||||||
|
payload[name] = el.checked;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (NUMBER_FIELDS.includes(name)) {
|
||||||
|
const num = el.value === "" ? null : Number(el.value);
|
||||||
|
if (num !== null && !Number.isNaN(num)) payload[name] = num;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = el.value.trim();
|
||||||
|
if (name === "gitlab_milestone_title") {
|
||||||
|
payload[name] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (value !== "") payload[name] = value;
|
||||||
|
}
|
||||||
|
payload.teams_calendar_enabled = document.getElementById("teamsEnabled").checked;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthStatus(data) {
|
||||||
|
const ptsEl = document.getElementById("ptsAuthStatus");
|
||||||
|
if (ptsEl) {
|
||||||
|
ptsEl.textContent = data.has_pts_session ? "已連線" : "未連線";
|
||||||
|
ptsEl.className = `badge ${data.has_pts_session ? "ok" : "warn"}`;
|
||||||
|
}
|
||||||
|
const teams = data.teams_calendar || data.graph_calendar || {};
|
||||||
|
const teamsEl = document.getElementById("teamsAuthStatus");
|
||||||
|
if (teamsEl) {
|
||||||
|
if (!data.teams_calendar_enabled) {
|
||||||
|
teamsEl.textContent = "未啟用";
|
||||||
|
teamsEl.className = "badge";
|
||||||
|
} else if (teams.connected) {
|
||||||
|
teamsEl.textContent = teams.auth_mode === "graph_api" ? "Graph API" : "已連線";
|
||||||
|
teamsEl.className = "badge ok";
|
||||||
|
} else {
|
||||||
|
teamsEl.textContent = "未連線";
|
||||||
|
teamsEl.className = "badge warn";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/status");
|
||||||
|
renderAuthStatus(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const [settings, dates] = await Promise.all([
|
||||||
|
api("/api/settings"),
|
||||||
|
api("/api/dates-file"),
|
||||||
|
]);
|
||||||
|
fillSettingsForm(settings);
|
||||||
|
document.getElementById("datesFileContent").value = dates.content || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const statusEl = document.getElementById("settingsStatus");
|
||||||
|
statusEl.textContent = "儲存中…";
|
||||||
|
statusEl.className = "settings-status";
|
||||||
|
const form = document.getElementById("settingsForm");
|
||||||
|
const payload = collectSettingsPayload(form);
|
||||||
|
const datesContent = document.getElementById("datesFileContent").value;
|
||||||
|
try {
|
||||||
|
await api("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
await api("/api/dates-file", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ content: datesContent }),
|
||||||
|
});
|
||||||
|
await loadSettings();
|
||||||
|
await refreshStatus();
|
||||||
|
statusEl.textContent = "已儲存";
|
||||||
|
statusEl.className = "settings-status ok";
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = err.message;
|
||||||
|
statusEl.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ptsLogin() {
|
||||||
|
const msg = document.getElementById("ptsLoginMsg");
|
||||||
|
const username = document.getElementById("ptsUsername").value.trim();
|
||||||
|
const password = document.getElementById("ptsPassword").value;
|
||||||
|
msg.textContent = "登入中…";
|
||||||
|
msg.className = "settings-status";
|
||||||
|
try {
|
||||||
|
const data = await api("/api/pts/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password, remember: true }),
|
||||||
|
});
|
||||||
|
document.getElementById("ptsPassword").value = "";
|
||||||
|
msg.textContent = data.message || "登入成功";
|
||||||
|
msg.className = "settings-status ok";
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = err.message;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ptsLogout() {
|
||||||
|
const msg = document.getElementById("ptsLoginMsg");
|
||||||
|
try {
|
||||||
|
await api("/api/pts/logout", { method: "POST" });
|
||||||
|
msg.textContent = "已清除 session";
|
||||||
|
msg.className = "settings-status";
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = err.message;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _mfaPreviewTimer = null;
|
||||||
|
let _mfaMetaTimer = null;
|
||||||
|
|
||||||
|
function vncPageUrl(port) {
|
||||||
|
const host = window.location.hostname || "localhost";
|
||||||
|
const p = port || 6080;
|
||||||
|
// autoconnect + scale to fit modal
|
||||||
|
return `http://${host}:${p}/vnc.html?autoconnect=1&resize=scale&reconnect=1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTeamsMfaModal(view) {
|
||||||
|
const modal = document.getElementById("teamsMfaModal");
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove("hidden");
|
||||||
|
const port = (view && view.vnc_port) || 6080;
|
||||||
|
const interactive = !!(view && view.interactive);
|
||||||
|
const link = document.getElementById("teamsVncLink");
|
||||||
|
const frame = document.getElementById("teamsVncFrame");
|
||||||
|
const url = vncPageUrl(port);
|
||||||
|
if (link) {
|
||||||
|
link.href = url;
|
||||||
|
link.style.display = interactive ? "" : "none";
|
||||||
|
}
|
||||||
|
if (frame) {
|
||||||
|
if (interactive) {
|
||||||
|
frame.src = url;
|
||||||
|
frame.style.display = "";
|
||||||
|
} else {
|
||||||
|
frame.removeAttribute("src");
|
||||||
|
frame.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hint = document.getElementById("teamsMfaHint");
|
||||||
|
if (hint) {
|
||||||
|
hint.innerHTML = interactive
|
||||||
|
? "帳密送出後,請在下方<strong>互動畫面</strong>完成驗證(可點數字、輸入驗證碼)。瀏覽器最多保持約 <strong>10 分鐘</strong>,完成前不會關掉。"
|
||||||
|
: "請在跳出的 <strong>Chromium 視窗</strong>完成 MFA。右側會顯示即時截圖(若有)。";
|
||||||
|
}
|
||||||
|
const status = document.getElementById("teamsMfaStatus");
|
||||||
|
if (status) {
|
||||||
|
status.textContent = (view && view.message) || "登入中…";
|
||||||
|
status.className = "settings-status";
|
||||||
|
}
|
||||||
|
startMfaPreviewPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTeamsMfaModal() {
|
||||||
|
stopMfaPreviewPoll();
|
||||||
|
const modal = document.getElementById("teamsMfaModal");
|
||||||
|
if (modal) modal.classList.add("hidden");
|
||||||
|
const frame = document.getElementById("teamsVncFrame");
|
||||||
|
if (frame) {
|
||||||
|
frame.removeAttribute("src");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMfaPreviewPoll() {
|
||||||
|
stopMfaPreviewPoll();
|
||||||
|
const img = document.getElementById("teamsMfaShot");
|
||||||
|
const meta = document.getElementById("teamsMfaShotMeta");
|
||||||
|
const status = document.getElementById("teamsMfaStatus");
|
||||||
|
const tick = () => {
|
||||||
|
if (img) {
|
||||||
|
img.src = `/api/teams/mfa-preview?t=${Date.now()}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
_mfaPreviewTimer = setInterval(tick, 1500);
|
||||||
|
_mfaMetaTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const view = await api("/api/teams/browser-view");
|
||||||
|
if (status && view.login_message) {
|
||||||
|
status.textContent = view.login_message;
|
||||||
|
}
|
||||||
|
if (meta) {
|
||||||
|
const parts = [];
|
||||||
|
if (view.mfa_preview_label) parts.push(view.mfa_preview_label);
|
||||||
|
if (view.mfa_page_url) parts.push(view.mfa_page_url.replace(/^https?:\/\//, "").slice(0, 48));
|
||||||
|
meta.textContent = parts.join(" · ") || "更新中";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMfaPreviewPoll() {
|
||||||
|
if (_mfaPreviewTimer) {
|
||||||
|
clearInterval(_mfaPreviewTimer);
|
||||||
|
_mfaPreviewTimer = null;
|
||||||
|
}
|
||||||
|
if (_mfaMetaTimer) {
|
||||||
|
clearInterval(_mfaMetaTimer);
|
||||||
|
_mfaMetaTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsLogin() {
|
||||||
|
const msg = document.getElementById("teamsLoginMsg");
|
||||||
|
const username = document.getElementById("teamsUsername").value.trim();
|
||||||
|
const password = document.getElementById("teamsPassword").value;
|
||||||
|
if (!username || !password) {
|
||||||
|
msg.textContent = "請輸入 Microsoft 帳號與密碼";
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msg.textContent = "登入中…(請在 MFA 畫面完成驗證)";
|
||||||
|
msg.className = "settings-status";
|
||||||
|
document.getElementById("teamsEnabled").checked = true;
|
||||||
|
|
||||||
|
let view = {};
|
||||||
|
try {
|
||||||
|
view = await api("/api/teams/browser-view");
|
||||||
|
} catch {
|
||||||
|
view = { interactive: false, vnc_port: 6080 };
|
||||||
|
}
|
||||||
|
openTeamsMfaModal(view);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api("/api/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ teams_calendar_enabled: true }),
|
||||||
|
});
|
||||||
|
const data = await api("/api/teams/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ username, password, remember: true, headless: false }),
|
||||||
|
});
|
||||||
|
document.getElementById("teamsPassword").value = "";
|
||||||
|
msg.textContent = data.message || "登入成功";
|
||||||
|
msg.className = "settings-status ok";
|
||||||
|
const st = document.getElementById("teamsMfaStatus");
|
||||||
|
if (st) {
|
||||||
|
st.textContent = data.message || "登入成功";
|
||||||
|
st.className = "settings-status ok";
|
||||||
|
}
|
||||||
|
await refreshStatus();
|
||||||
|
setTimeout(closeTeamsMfaModal, 1500);
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = err.message;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
const st = document.getElementById("teamsMfaStatus");
|
||||||
|
if (st) {
|
||||||
|
st.textContent = err.message;
|
||||||
|
st.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsLogout() {
|
||||||
|
const msg = document.getElementById("teamsLoginMsg");
|
||||||
|
try {
|
||||||
|
await api("/api/teams/logout", { method: "POST" });
|
||||||
|
msg.textContent = "已中斷";
|
||||||
|
msg.className = "settings-status";
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (err) {
|
||||||
|
msg.textContent = err.message;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTeamsTestDates() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const startEl = document.getElementById("teamsTestStart");
|
||||||
|
const endEl = document.getElementById("teamsTestEnd");
|
||||||
|
if (startEl && !startEl.value) startEl.value = today;
|
||||||
|
if (endEl && !endEl.value) endEl.value = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsShowStatus() {
|
||||||
|
const msg = document.getElementById("teamsTestMsg");
|
||||||
|
const debug = document.getElementById("teamsTestDebug");
|
||||||
|
try {
|
||||||
|
const data = await api("/api/teams/status");
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = data.connected
|
||||||
|
? `連線中(${data.auth_mode || "session"})`
|
||||||
|
: "未連線或 session 過期,請先登入 Teams";
|
||||||
|
msg.className = `settings-status ${data.connected ? "ok" : "err"}`;
|
||||||
|
}
|
||||||
|
if (debug) debug.textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = err.message;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teamsTestMeetings() {
|
||||||
|
const msg = document.getElementById("teamsTestMsg");
|
||||||
|
const summary = document.getElementById("teamsTestSummary");
|
||||||
|
const list = document.getElementById("teamsTestList");
|
||||||
|
const debug = document.getElementById("teamsTestDebug");
|
||||||
|
const start = document.getElementById("teamsTestStart")?.value;
|
||||||
|
const end = document.getElementById("teamsTestEnd")?.value || start;
|
||||||
|
const skip = document.getElementById("teamsTestSkipHolidays")?.checked !== false;
|
||||||
|
|
||||||
|
if (!start) {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = "請選開始日期";
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = "測試中…(可能開瀏覽器做 MFA)";
|
||||||
|
msg.className = "settings-status";
|
||||||
|
}
|
||||||
|
if (list) list.innerHTML = "";
|
||||||
|
if (summary) summary.innerHTML = "";
|
||||||
|
if (debug) debug.textContent = "請求中…";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
start_date: start,
|
||||||
|
end_date: end,
|
||||||
|
skip_holidays: skip ? "true" : "false",
|
||||||
|
debug: "true",
|
||||||
|
});
|
||||||
|
const data = await api(`/api/teams/meetings?${q.toString()}`);
|
||||||
|
|
||||||
|
if (summary) {
|
||||||
|
summary.innerHTML = [
|
||||||
|
`<span class="pill"><span class="pill-label">會議</span><span class="pill-value">${data.total_count ?? 0}</span></span>`,
|
||||||
|
`<span class="pill"><span class="pill-label">時數</span><span class="pill-value">${data.total_hours ?? 0}h</span></span>`,
|
||||||
|
`<span class="pill"><span class="pill-label">天數</span><span class="pill-value">${data.workday_count ?? 0}</span></span>`,
|
||||||
|
`<span class="pill"><span class="pill-label">連線</span><span class="pill-value">${data.connected ? "是" : "否"}</span></span>`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = (data.days || []).filter((d) => d.count > 0);
|
||||||
|
if (list) {
|
||||||
|
if (!days.length) {
|
||||||
|
list.innerHTML = `<p class="hint">${escapeHtml(data.fetch_hint || data.error || "0 筆會議")}</p>`;
|
||||||
|
} else {
|
||||||
|
list.innerHTML = days
|
||||||
|
.map((day) => {
|
||||||
|
const rows = (day.meetings || [])
|
||||||
|
.map(
|
||||||
|
(m) =>
|
||||||
|
`<li class="plan-row"><span class="meeting-hours">${escapeHtml(String(m.hours))}h</span>` +
|
||||||
|
`<span class="meeting-time">${escapeHtml(m.time_label || "")}</span>` +
|
||||||
|
`<span class="meeting-subject">${escapeHtml(m.subject || "")}</span></li>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return `<div class="plan-day"><div class="plan-day-head"><strong>${escapeHtml(day.date)}</strong>` +
|
||||||
|
`<span class="muted-inline">${day.count} 場 · ${day.hours}h</span></div>` +
|
||||||
|
`<ul class="meeting-items">${rows}</ul></div>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
if (data.error) {
|
||||||
|
msg.textContent = data.error;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
} else if ((data.total_count || 0) > 0) {
|
||||||
|
msg.textContent = `成功:${data.total_count} 場會議 · ${data.total_hours}h`;
|
||||||
|
msg.className = "settings-status ok";
|
||||||
|
} else {
|
||||||
|
msg.textContent = data.fetch_hint || "完成但 0 筆會議";
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (debug) debug.textContent = JSON.stringify(data, null, 2);
|
||||||
|
} catch (err) {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = err.message;
|
||||||
|
msg.className = "settings-status err";
|
||||||
|
}
|
||||||
|
if (debug) debug.textContent = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("settingsForm").addEventListener("submit", saveSettings);
|
||||||
|
document.getElementById("ptsLoginBtn").addEventListener("click", ptsLogin);
|
||||||
|
document.getElementById("ptsLogoutBtn").addEventListener("click", ptsLogout);
|
||||||
|
document.getElementById("teamsLoginBtn").addEventListener("click", teamsLogin);
|
||||||
|
document.getElementById("teamsLogoutBtn").addEventListener("click", teamsLogout);
|
||||||
|
document.getElementById("teamsTestBtn")?.addEventListener("click", () => {
|
||||||
|
teamsTestMeetings().catch(console.error);
|
||||||
|
});
|
||||||
|
document.getElementById("teamsStatusBtn")?.addEventListener("click", () => {
|
||||||
|
teamsShowStatus().catch(console.error);
|
||||||
|
});
|
||||||
|
document.getElementById("teamsMfaClose")?.addEventListener("click", () => {
|
||||||
|
// Only hide preview; login request keeps running until MFA done or timeout
|
||||||
|
closeTeamsMfaModal();
|
||||||
|
});
|
||||||
|
document.getElementById("teamsMfaShot")?.addEventListener("error", () => {
|
||||||
|
/* 404 until first snapshot — ignore */
|
||||||
|
});
|
||||||
|
|
||||||
|
initTeamsTestDates();
|
||||||
|
loadSettings().then(refreshStatus).catch(console.error);
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,12 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [ ! -d ".venv" ]; then
|
||||||
|
python3 -m venv .venv
|
||||||
|
fi
|
||||||
|
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -q -r backend/requirements.txt
|
||||||
|
cd backend
|
||||||
|
python main.py
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start PTS in Docker with zero host data persistence (tmpfs /data only).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
echo "==> 關閉本地 Python 伺服器(避免佔用 8765)"
|
||||||
|
"$ROOT/scripts/server.sh" stop 2>/dev/null || true
|
||||||
|
pgrep -f "$ROOT/backend.*main.py" 2>/dev/null | xargs kill 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> 建置並啟動 Docker(無 volume、不寫入本機 pts-data;.dockerignore 排除 secrets)"
|
||||||
|
cd "$ROOT"
|
||||||
|
# Full stop: remove container; no host data mount to clean
|
||||||
|
docker compose down --remove-orphans 2>/dev/null || true
|
||||||
|
# Rebuild so COPY respects latest .dockerignore
|
||||||
|
docker compose build --pull
|
||||||
|
docker compose up -d --force-recreate --remove-orphans
|
||||||
|
|
||||||
|
PORT="${BACKEND_PORT:-8765}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "吉八小 · 集滿八小時(容器,不留本機資料)已啟動"
|
||||||
|
echo " Web UI: http://localhost:$PORT"
|
||||||
|
echo " 資料: 僅容器內 tmpfs /data(停止/刪除容器即消失,不寫本機)"
|
||||||
|
echo " 查看 log: make docker-logs"
|
||||||
|
echo " 停止: make docker-down"
|
||||||
|
echo ""
|
||||||
|
echo "首次請在網頁登入 PTS(與選用 Teams);帳密只留在容器記憶體,不會存到主機。"
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
PID_FILE="$ROOT/backend/data/server.pid"
|
||||||
|
LOG_FILE="$ROOT/backend/data/server.log"
|
||||||
|
PYTHON="$ROOT/.venv/bin/python"
|
||||||
|
PORT="8765"
|
||||||
|
|
||||||
|
read_port() {
|
||||||
|
if [ -f "$ROOT/.env" ]; then
|
||||||
|
local value
|
||||||
|
value="$(grep -E '^BACKEND_PORT=' "$ROOT/.env" | tail -1 | cut -d= -f2 | tr -d ' \r' || true)"
|
||||||
|
if [ -n "$value" ]; then
|
||||||
|
PORT="$value"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
is_running() {
|
||||||
|
if [ ! -f "$PID_FILE" ]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local pid
|
||||||
|
pid="$(cat "$PID_FILE")"
|
||||||
|
kill -0 "$pid" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
start_server() {
|
||||||
|
read_port
|
||||||
|
|
||||||
|
if [ ! -x "$PYTHON" ]; then
|
||||||
|
echo "Missing virtualenv. Run: make setup"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_running; then
|
||||||
|
echo "Server already running (PID $(cat "$PID_FILE")) — http://localhost:$PORT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$PID_FILE")"
|
||||||
|
cd "$ROOT/backend"
|
||||||
|
PTS_RELOAD=false nohup "$PYTHON" main.py >>"$LOG_FILE" 2>&1 &
|
||||||
|
echo $! >"$PID_FILE"
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
if is_running; then
|
||||||
|
echo "Started PID $(cat "$PID_FILE") — http://localhost:$PORT"
|
||||||
|
echo "Log: $LOG_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Failed to start server. Check $LOG_FILE"
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_server() {
|
||||||
|
read_port
|
||||||
|
local pid=""
|
||||||
|
|
||||||
|
if is_running; then
|
||||||
|
pid="$(cat "$PID_FILE")"
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || break
|
||||||
|
sleep 0.3
|
||||||
|
done
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
echo "Stopped server (PID $pid)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
local pids=""
|
||||||
|
while IFS= read -r pid; do
|
||||||
|
[ -n "$pid" ] || continue
|
||||||
|
local cmd
|
||||||
|
cmd="$(ps -p "$pid" -o command= 2>/dev/null || true)"
|
||||||
|
case "$cmd" in
|
||||||
|
*"$ROOT/backend"*|*main.py*|*uvicorn*main:app*)
|
||||||
|
pids="$pids $pid"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done < <(lsof -ti :"$PORT" 2>/dev/null || true)
|
||||||
|
|
||||||
|
pids="$(echo "$pids" | xargs)"
|
||||||
|
if [ -n "$pids" ]; then
|
||||||
|
echo "$pids" | xargs kill 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||||
|
echo "Stopped local PTS server on port $PORT"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Server not running"
|
||||||
|
}
|
||||||
|
|
||||||
|
server_status() {
|
||||||
|
read_port
|
||||||
|
if is_running; then
|
||||||
|
echo "Running PID $(cat "$PID_FILE") — http://localhost:$PORT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Not running"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
start) start_server ;;
|
||||||
|
stop) stop_server ;;
|
||||||
|
restart)
|
||||||
|
stop_server || true
|
||||||
|
sleep 0.5
|
||||||
|
start_server
|
||||||
|
;;
|
||||||
|
status) server_status ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {start|stop|restart|status}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Loading…
Reference in New Issue