feat: add Feishu daily report app
This commit is contained in:
commit
ebe9d5684a
10
.env.example
Normal file
10
.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
PORT=8787
|
||||
BASE_URL=http://localhost:8787
|
||||
DATABASE_PATH=data/daily-report.sqlite
|
||||
EMPLOYEE_SEED_PATH=data/employees.json
|
||||
WORKDAY_CALENDAR_PATH=data/workday-calendar.json
|
||||
FEISHU_WEBHOOK_URL=
|
||||
FEISHU_WEBHOOK_SECRET=
|
||||
FEISHU_APP_ID=
|
||||
FEISHU_APP_SECRET=
|
||||
SESSION_SECRET=
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.env
|
||||
data/*.sqlite
|
||||
data/*.sqlite-shm
|
||||
data/*.sqlite-wal
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.log
|
||||
145
README.md
Normal file
145
README.md
Normal file
@ -0,0 +1,145 @@
|
||||
# 飞书每日工作汇报系统
|
||||
|
||||
这是一个轻量的日报提交和浏览工具。员工通过飞书机器人提醒进入填写页提交日报,管理者在浏览页查看所有记录和未提交名单。
|
||||
|
||||
## 本地启动
|
||||
|
||||
本项目只使用 Python 标准库,不需要安装第三方依赖。
|
||||
|
||||
```bash
|
||||
python -m daily_report.web
|
||||
```
|
||||
|
||||
默认地址:
|
||||
|
||||
- 提交页:http://localhost:8787/submit
|
||||
- 管理页:http://localhost:8787/manager
|
||||
|
||||
## 配置
|
||||
|
||||
复制 `.env.example` 为 `.env`,或直接设置环境变量:
|
||||
|
||||
- `PORT`:服务端口,默认 `8787`
|
||||
- `BASE_URL`:飞书消息里使用的外部访问地址
|
||||
- `DATABASE_PATH`:SQLite 数据库路径
|
||||
- `EMPLOYEE_SEED_PATH`:员工名单 JSON 路径
|
||||
- `WORKDAY_CALENDAR_PATH`:国家法定工作日历 JSON 路径
|
||||
- `FEISHU_WEBHOOK_URL`:飞书自定义机器人 Webhook
|
||||
- `FEISHU_WEBHOOK_SECRET`:预留字段,后续用于签名校验
|
||||
- `FEISHU_APP_ID`:飞书企业自建应用的 App ID,用于自动识别填写人
|
||||
- `FEISHU_APP_SECRET`:飞书企业自建应用的 App Secret
|
||||
- `SESSION_SECRET`:本系统登录 Cookie 签名密钥,可以填一串自己生成的随机字符
|
||||
|
||||
## 员工名单
|
||||
|
||||
第一版通过 `data/employees.json` 维护员工名单。每个员工需要:
|
||||
|
||||
- `feishu_user_id`
|
||||
- `name`
|
||||
- `department`
|
||||
- `manager`
|
||||
- `active`
|
||||
|
||||
修改员工名单后,重启服务会自动同步到 SQLite。
|
||||
|
||||
## 飞书机器人
|
||||
|
||||
在飞书群添加自定义机器人后,将 Webhook 地址配置到 `FEISHU_WEBHOOK_URL`。
|
||||
|
||||
手动发送提醒:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/robot/send-reminder
|
||||
```
|
||||
|
||||
手动发送汇总:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8787/api/robot/send-summary -H "content-type: application/json" -d "{\"date\":\"2026-05-07\"}"
|
||||
```
|
||||
|
||||
## 飞书自动识别填写人
|
||||
|
||||
要让员工打开填写页时自动识别身份,需要使用飞书企业自建应用。
|
||||
|
||||
在 `.env` 中填写:
|
||||
|
||||
```dotenv
|
||||
FEISHU_APP_ID=你的 App ID
|
||||
FEISHU_APP_SECRET=你的 App Secret
|
||||
SESSION_SECRET=任意一串较长随机字符
|
||||
```
|
||||
|
||||
然后在飞书开放平台应用后台配置网页应用重定向地址:
|
||||
|
||||
```text
|
||||
http://你的可访问地址:8787/auth/feishu/callback
|
||||
```
|
||||
|
||||
这个地址里的域名和端口要与 `BASE_URL` 一致。配置后重启本系统,员工打开 `/submit` 会先跳转到飞书授权,授权完成后自动回到填写页,并自动带出当前填写人。
|
||||
|
||||
如果暂时不填 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET`,系统会退回手动填写员工 ID 的模式。
|
||||
|
||||
## Windows 任务计划
|
||||
|
||||
本项目提供 Windows 任务计划脚本。任务每天触发,但发送前会读取 `data/workday-calendar.json`,只在国家法定工作日发送:
|
||||
|
||||
- 工作日 18:00:日报填写提醒
|
||||
- 工作日 19:00:日报提交汇总
|
||||
|
||||
这意味着:
|
||||
|
||||
- 周一到周五如果是法定假期,不发送。
|
||||
- 周六、周日如果是调休补班,会发送。
|
||||
|
||||
任务计划会直接运行 Python 模块发送飞书消息,不要求 Web 服务正在运行。但 `BASE_URL` 必须是员工能打开的地址。
|
||||
|
||||
安装任务:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
|
||||
.\scripts\install-windows-tasks.ps1
|
||||
```
|
||||
|
||||
也可以直接双击:
|
||||
|
||||
```text
|
||||
install-tasks.bat
|
||||
```
|
||||
|
||||
如果你的 Python 不在默认路径,可以指定:
|
||||
|
||||
```powershell
|
||||
.\scripts\install-windows-tasks.ps1 -PythonPath "C:\Path\To\python.exe"
|
||||
```
|
||||
|
||||
手动测试提醒:
|
||||
|
||||
```powershell
|
||||
python -m daily_report.scheduled reminder
|
||||
```
|
||||
|
||||
手动测试汇总:
|
||||
|
||||
```powershell
|
||||
python -m daily_report.scheduled summary
|
||||
```
|
||||
|
||||
卸载任务:
|
||||
|
||||
```powershell
|
||||
.\scripts\uninstall-windows-tasks.ps1
|
||||
```
|
||||
|
||||
也可以直接双击:
|
||||
|
||||
```text
|
||||
uninstall-tasks.bat
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests
|
||||
python -m py_compile daily_report/config.py daily_report/db.py daily_report/report_service.py daily_report/robot_service.py daily_report/scheduled.py daily_report/web.py daily_report/workday.py
|
||||
```
|
||||
2
daily_report/__init__.py
Normal file
2
daily_report/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Feishu daily report application."""
|
||||
|
||||
57
daily_report/config.py
Normal file
57
daily_report/config.py
Normal file
@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
root_dir: Path
|
||||
port: int
|
||||
base_url: str
|
||||
database_path: Path
|
||||
employee_seed_path: Path
|
||||
workday_calendar_path: Path
|
||||
feishu_webhook_url: str
|
||||
feishu_webhook_secret: str
|
||||
feishu_app_id: str
|
||||
feishu_app_secret: str
|
||||
session_secret: str
|
||||
|
||||
|
||||
def _read_dotenv(path: Path) -> dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||
return values
|
||||
|
||||
|
||||
def read_config(env: dict[str, str] | None = None) -> Config:
|
||||
root_dir = Path(__file__).resolve().parent.parent
|
||||
dotenv_values = _read_dotenv(root_dir / ".env")
|
||||
values = {**dotenv_values, **dict(env if env is not None else os.environ)}
|
||||
port = int(values.get("PORT", "8787"))
|
||||
if port <= 0:
|
||||
raise ValueError("PORT must be a positive integer")
|
||||
|
||||
return Config(
|
||||
root_dir=root_dir,
|
||||
port=port,
|
||||
base_url=values.get("BASE_URL", f"http://localhost:{port}"),
|
||||
database_path=(root_dir / values.get("DATABASE_PATH", "data/daily-report.sqlite")).resolve(),
|
||||
employee_seed_path=(root_dir / values.get("EMPLOYEE_SEED_PATH", "data/employees.json")).resolve(),
|
||||
workday_calendar_path=(root_dir / values.get("WORKDAY_CALENDAR_PATH", "data/workday-calendar.json")).resolve(),
|
||||
feishu_webhook_url=values.get("FEISHU_WEBHOOK_URL", ""),
|
||||
feishu_webhook_secret=values.get("FEISHU_WEBHOOK_SECRET", ""),
|
||||
feishu_app_id=values.get("FEISHU_APP_ID", ""),
|
||||
feishu_app_secret=values.get("FEISHU_APP_SECRET", ""),
|
||||
session_secret=values.get("SESSION_SECRET") or values.get("FEISHU_APP_SECRET") or "dev-session-secret",
|
||||
)
|
||||
162
daily_report/db.py
Normal file
162
daily_report/db.py
Normal file
@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, database_path: Path, employee_seed_path: Path):
|
||||
database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.connection = sqlite3.connect(database_path, check_same_thread=False)
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
self._lock = threading.RLock()
|
||||
self._migrate()
|
||||
self.load_employees(employee_seed_path)
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self.connection.close()
|
||||
|
||||
def _migrate(self) -> None:
|
||||
with self._lock:
|
||||
self.connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feishu_user_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
department TEXT NOT NULL DEFAULT '',
|
||||
manager TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
feishu_user_id TEXT NOT NULL,
|
||||
employee_name TEXT NOT NULL,
|
||||
report_date TEXT NOT NULL,
|
||||
today_done TEXT NOT NULL,
|
||||
tomorrow_plan TEXT NOT NULL,
|
||||
blockers TEXT NOT NULL DEFAULT '',
|
||||
help_needed TEXT NOT NULL DEFAULT '',
|
||||
submitted_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(feishu_user_id, report_date)
|
||||
);
|
||||
"""
|
||||
)
|
||||
self.connection.commit()
|
||||
|
||||
def load_employees(self, employee_seed_path: Path) -> None:
|
||||
if not employee_seed_path.exists():
|
||||
return
|
||||
|
||||
employees = json.loads(employee_seed_path.read_text(encoding="utf-8"))
|
||||
self.upsert_employees(employees)
|
||||
|
||||
def upsert_employees(self, employees: list[dict[str, Any]]) -> None:
|
||||
with self._lock, self.connection:
|
||||
for employee in employees:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO employees (feishu_user_id, name, department, manager, active, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(feishu_user_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
department = excluded.department,
|
||||
manager = excluded.manager,
|
||||
active = excluded.active,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(
|
||||
employee["feishu_user_id"],
|
||||
employee["name"],
|
||||
employee.get("department", ""),
|
||||
employee.get("manager", ""),
|
||||
0 if employee.get("active") is False else 1,
|
||||
),
|
||||
)
|
||||
|
||||
def upsert_employee(self, employee: dict[str, Any]) -> None:
|
||||
self.upsert_employees([employee])
|
||||
|
||||
def list_active_employees(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT feishu_user_id, name, department, manager, active
|
||||
FROM employees
|
||||
WHERE active = 1
|
||||
ORDER BY department, name
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def find_employee(self, feishu_user_id: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
row = self.connection.execute(
|
||||
"""
|
||||
SELECT feishu_user_id, name, department, manager, active
|
||||
FROM employees
|
||||
WHERE feishu_user_id = ?
|
||||
""",
|
||||
(feishu_user_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def upsert_report(self, report: dict[str, Any]) -> None:
|
||||
with self._lock, self.connection:
|
||||
self.connection.execute(
|
||||
"""
|
||||
INSERT INTO daily_reports (
|
||||
feishu_user_id, employee_name, report_date, today_done, tomorrow_plan,
|
||||
blockers, help_needed, submitted_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
:feishu_user_id, :employee_name, :report_date, :today_done, :tomorrow_plan,
|
||||
:blockers, :help_needed, :submitted_at, :updated_at
|
||||
)
|
||||
ON CONFLICT(feishu_user_id, report_date) DO UPDATE SET
|
||||
employee_name = excluded.employee_name,
|
||||
today_done = excluded.today_done,
|
||||
tomorrow_plan = excluded.tomorrow_plan,
|
||||
blockers = excluded.blockers,
|
||||
help_needed = excluded.help_needed,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
report,
|
||||
)
|
||||
|
||||
def list_reports_for_date(self, report_date: str) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT id, feishu_user_id, employee_name, report_date, today_done,
|
||||
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
|
||||
FROM daily_reports
|
||||
WHERE report_date = ?
|
||||
ORDER BY employee_name
|
||||
""",
|
||||
(report_date,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_reports_for_employee(self, feishu_user_id: str, limit: int = 10) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
rows = self.connection.execute(
|
||||
"""
|
||||
SELECT id, feishu_user_id, employee_name, report_date, today_done,
|
||||
tomorrow_plan, blockers, help_needed, submitted_at, updated_at
|
||||
FROM daily_reports
|
||||
WHERE feishu_user_id = ?
|
||||
ORDER BY report_date DESC, updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(feishu_user_id, limit),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
112
daily_report/feishu_auth.py
Normal file
112
daily_report/feishu_auth.py
Normal file
@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
AUTHORIZE_URL = "https://open.feishu.cn/open-apis/authen/v1/authorize"
|
||||
TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token"
|
||||
USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info"
|
||||
|
||||
|
||||
def _b64encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _b64decode(text: str) -> bytes:
|
||||
padding = "=" * (-len(text) % 4)
|
||||
return base64.urlsafe_b64decode(text + padding)
|
||||
|
||||
|
||||
def create_session_cookie(session: dict[str, str], secret: str) -> str:
|
||||
payload = _b64encode(json.dumps(session, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
||||
signature = hmac.new(secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{payload}.{signature}"
|
||||
|
||||
|
||||
def parse_session_cookie(cookie_value: str | None, secret: str) -> dict[str, str] | None:
|
||||
if not cookie_value or "." not in cookie_value:
|
||||
return None
|
||||
|
||||
payload, signature = cookie_value.rsplit(".", 1)
|
||||
expected = hmac.new(secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(_b64decode(payload).decode("utf-8"))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict) or not data.get("feishu_user_id"):
|
||||
return None
|
||||
return {str(key): str(value) for key, value in data.items()}
|
||||
|
||||
|
||||
def create_state() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def authorize_url(app_id: str, redirect_uri: str, state: str) -> str:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"app_id": app_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
return f"{AUTHORIZE_URL}?{query}"
|
||||
|
||||
|
||||
def exchange_code_for_user_access_token(app_id: str, app_secret: str, code: str, redirect_uri: str) -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
TOKEN_URL,
|
||||
data=payload,
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
token = result.get("access_token") or result.get("data", {}).get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError(f"Feishu token response missing access_token: {result}")
|
||||
return str(token)
|
||||
|
||||
|
||||
def fetch_user_info(user_access_token: str) -> dict[str, Any]:
|
||||
request = urllib.request.Request(
|
||||
USER_INFO_URL,
|
||||
headers={"authorization": f"Bearer {user_access_token}"},
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
data = result.get("data", result)
|
||||
user_id = data.get("user_id") or data.get("open_id") or data.get("union_id")
|
||||
name = data.get("name") or data.get("en_name") or data.get("email") or user_id
|
||||
if not user_id:
|
||||
raise RuntimeError(f"Feishu user_info response missing user id: {result}")
|
||||
|
||||
return {
|
||||
"feishu_user_id": str(user_id),
|
||||
"name": str(name),
|
||||
"department": "",
|
||||
"manager": "",
|
||||
"active": True,
|
||||
}
|
||||
100
daily_report/report_service.py
Normal file
100
daily_report/report_service.py
Normal file
@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from .db import Database
|
||||
|
||||
|
||||
Clock = Callable[[], datetime]
|
||||
|
||||
|
||||
def _required_text(value: Any, field_name: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ValueError(f"{field_name} is required")
|
||||
return text
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
class ReportService:
|
||||
def __init__(self, database: Database, clock: Clock | None = None):
|
||||
self.database = database
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
|
||||
def upsert_report(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
feishu_user_id = _required_text(data.get("feishu_user_id"), "feishu_user_id")
|
||||
report_date = _required_text(data.get("report_date"), "report_date")
|
||||
today_done = _required_text(data.get("today_done"), "today_done")
|
||||
tomorrow_plan = _required_text(data.get("tomorrow_plan"), "tomorrow_plan")
|
||||
employee = self.database.find_employee(feishu_user_id)
|
||||
if not employee or employee.get("active") != 1:
|
||||
raise ValueError("employee is not active")
|
||||
|
||||
now = self.clock().isoformat()
|
||||
self.database.upsert_report(
|
||||
{
|
||||
"feishu_user_id": feishu_user_id,
|
||||
"employee_name": employee["name"],
|
||||
"report_date": report_date,
|
||||
"today_done": today_done,
|
||||
"tomorrow_plan": tomorrow_plan,
|
||||
"blockers": _optional_text(data.get("blockers")),
|
||||
"help_needed": _optional_text(data.get("help_needed")),
|
||||
"submitted_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
return next(
|
||||
report
|
||||
for report in self.list_reports_for_date(report_date)["reports"]
|
||||
if report["feishu_user_id"] == feishu_user_id
|
||||
)
|
||||
|
||||
def list_reports_for_date(self, report_date: str) -> dict[str, Any]:
|
||||
date = _required_text(report_date, "report_date")
|
||||
employees = self.database.list_active_employees()
|
||||
reports = self.database.list_reports_for_date(date)
|
||||
submitted_ids = {report["feishu_user_id"] for report in reports}
|
||||
missing = [employee for employee in employees if employee["feishu_user_id"] not in submitted_ids]
|
||||
return {
|
||||
"date": date,
|
||||
"expectedCount": len(employees),
|
||||
"submittedCount": len(reports),
|
||||
"missingCount": len(missing),
|
||||
"reports": reports,
|
||||
"missing": missing,
|
||||
}
|
||||
|
||||
def export_reports_csv(self, report_date: str) -> str:
|
||||
result = self.list_reports_for_date(report_date)
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"employee_name",
|
||||
"report_date",
|
||||
"today_done",
|
||||
"tomorrow_plan",
|
||||
"blockers",
|
||||
"help_needed",
|
||||
"submitted_at",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(result["reports"])
|
||||
return output.getvalue()
|
||||
|
||||
def list_employee_history(self, feishu_user_id: str, limit: int = 10) -> dict[str, Any]:
|
||||
user_id = _required_text(feishu_user_id, "feishu_user_id")
|
||||
employee = self.database.find_employee(user_id)
|
||||
if not employee or employee.get("active") != 1:
|
||||
raise ValueError("employee is not active")
|
||||
|
||||
return {
|
||||
"employee": employee,
|
||||
"reports": self.database.list_reports_for_employee(user_id, limit),
|
||||
}
|
||||
72
daily_report/robot_service.py
Normal file
72
daily_report/robot_service.py
Normal file
@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _button(text: str, url: str) -> dict[str, Any]:
|
||||
return {
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": text},
|
||||
"type": "primary",
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
def create_reminder_payload(submit_url: str, title: str = "每日工作汇报提醒") -> dict[str, Any]:
|
||||
return {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
"template": "blue",
|
||||
},
|
||||
"elements": [
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": "请提交今日日报。"}},
|
||||
{"tag": "action", "actions": [_button("填写日报", submit_url)]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_summary_payload(manager_url: str, summary: dict[str, Any]) -> dict[str, Any]:
|
||||
missing_names = "、".join(employee["name"] for employee in summary["missing"]) or "无"
|
||||
return {
|
||||
"msg_type": "interactive",
|
||||
"card": {
|
||||
"header": {
|
||||
"title": {"tag": "plain_text", "content": f"{summary['date']} 日报提交汇总"},
|
||||
"template": "orange" if summary["missingCount"] > 0 else "green",
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"已提交:{summary['submittedCount']}/{summary['expectedCount']}\n未提交:{missing_names}",
|
||||
},
|
||||
},
|
||||
{"tag": "action", "actions": [_button("查看全部日报", manager_url)]},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def send_webhook(webhook_url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not webhook_url:
|
||||
raise ValueError("FEISHU_WEBHOOK_URL is required")
|
||||
|
||||
request = urllib.request.Request(
|
||||
webhook_url,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=10) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
return json.loads(body) if body else {"ok": True}
|
||||
except urllib.error.HTTPError as error:
|
||||
raise RuntimeError(f"Feishu webhook failed with status {error.code}") from error
|
||||
52
daily_report/scheduled.py
Normal file
52
daily_report/scheduled.py
Normal file
@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import date
|
||||
|
||||
from .config import read_config
|
||||
from .db import Database
|
||||
from .report_service import ReportService
|
||||
from .robot_service import create_reminder_payload, create_summary_payload, send_webhook
|
||||
from .workday import is_workday
|
||||
|
||||
|
||||
def send_reminder() -> dict:
|
||||
config = read_config()
|
||||
if not is_workday(date.today(), config.workday_calendar_path):
|
||||
return {"skipped": True, "reason": "not a workday"}
|
||||
payload = create_reminder_payload(f"{config.base_url}/submit")
|
||||
return send_webhook(config.feishu_webhook_url, payload)
|
||||
|
||||
|
||||
def send_summary(report_date: str | None = None) -> dict:
|
||||
config = read_config()
|
||||
selected_date = date.fromisoformat(report_date) if report_date else date.today()
|
||||
if not is_workday(selected_date, config.workday_calendar_path):
|
||||
return {"skipped": True, "reason": "not a workday", "date": selected_date.isoformat()}
|
||||
|
||||
database = Database(config.database_path, config.employee_seed_path)
|
||||
try:
|
||||
service = ReportService(database)
|
||||
selected_date_text = selected_date.isoformat()
|
||||
summary = service.list_reports_for_date(selected_date_text)
|
||||
payload = create_summary_payload(f"{config.base_url}/manager?date={selected_date_text}", summary)
|
||||
return send_webhook(config.feishu_webhook_url, payload)
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="发送飞书日报定时消息")
|
||||
parser.add_argument("action", choices=["reminder", "summary"], help="reminder 发送提醒,summary 发送汇总")
|
||||
parser.add_argument("--date", help="汇总日期,格式 YYYY-MM-DD;不传则使用当天")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.action == "reminder":
|
||||
result = send_reminder()
|
||||
else:
|
||||
result = send_summary(args.date)
|
||||
print(result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
daily_report/static/manager.js
Normal file
67
daily_report/static/manager.js
Normal file
@ -0,0 +1,67 @@
|
||||
const state = { data: null };
|
||||
|
||||
function text(value) {
|
||||
return String(value || "");
|
||||
}
|
||||
|
||||
function render() {
|
||||
const employeeQuery = document.querySelector("#employee-filter").value.trim().toLowerCase();
|
||||
const keywordQuery = document.querySelector("#keyword-filter").value.trim().toLowerCase();
|
||||
const onlyBlockers = document.querySelector("#blocker-filter").checked;
|
||||
const data = state.data;
|
||||
|
||||
document.querySelector("#stats").innerHTML = `
|
||||
<strong>应提交:${data.expectedCount}</strong>
|
||||
<strong>已提交:${data.submittedCount}</strong>
|
||||
<strong>未提交:${data.missingCount}</strong>
|
||||
`;
|
||||
|
||||
const reports = data.reports.filter((report) => {
|
||||
const employeeMatch = report.employee_name.toLowerCase().includes(employeeQuery);
|
||||
const searchable = [report.today_done, report.tomorrow_plan, report.blockers, report.help_needed].join(" ").toLowerCase();
|
||||
const keywordMatch = searchable.includes(keywordQuery);
|
||||
const blockerMatch = !onlyBlockers || report.blockers || report.help_needed;
|
||||
return employeeMatch && keywordMatch && blockerMatch;
|
||||
});
|
||||
|
||||
document.querySelector("#reports").innerHTML = reports.length ? reports.map((report) => `
|
||||
<article class="report-card">
|
||||
<h3>${report.employee_name}</h3>
|
||||
<div class="report-grid">
|
||||
<div><div class="field-title">今日完成</div><div>${text(report.today_done)}</div></div>
|
||||
<div><div class="field-title">明日计划</div><div>${text(report.tomorrow_plan)}</div></div>
|
||||
<div><div class="field-title">遇到的问题</div><div>${text(report.blockers) || "无"}</div></div>
|
||||
<div><div class="field-title">需要协助</div><div>${text(report.help_needed) || "无"}</div></div>
|
||||
</div>
|
||||
<p>提交时间:${report.updated_at}</p>
|
||||
</article>
|
||||
`).join("") : "<p>当前筛选下没有日报。</p>";
|
||||
|
||||
document.querySelector("#missing").textContent = data.missing.length
|
||||
? data.missing.map((employee) => employee.name).join("、")
|
||||
: "无";
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const date = document.querySelector("#date-filter").value;
|
||||
document.querySelector("#export-link").href = `/api/reports/export?date=${date}`;
|
||||
const response = await fetch(`/api/reports?date=${date}`);
|
||||
state.data = await response.json();
|
||||
render();
|
||||
}
|
||||
|
||||
document.querySelector("#date-filter").addEventListener("change", loadReports);
|
||||
document.querySelector("#employee-filter").addEventListener("input", render);
|
||||
document.querySelector("#keyword-filter").addEventListener("input", render);
|
||||
document.querySelector("#blocker-filter").addEventListener("change", render);
|
||||
document.querySelector("#copy-summary").addEventListener("click", async () => {
|
||||
const data = state.data;
|
||||
const lines = [
|
||||
`${data.date} 日报汇总`,
|
||||
`已提交:${data.submittedCount}/${data.expectedCount}`,
|
||||
`未提交:${data.missing.length ? data.missing.map((employee) => employee.name).join("、") : "无"}`
|
||||
];
|
||||
await navigator.clipboard.writeText(lines.join("\n"));
|
||||
});
|
||||
|
||||
loadReports();
|
||||
73
daily_report/static/styles.css
Normal file
73
daily_report/static/styles.css
Normal file
@ -0,0 +1,73 @@
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, "Microsoft YaHei", sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #1f2937;
|
||||
}
|
||||
.shell { width: min(1120px, calc(100% - 32px)); margin: 32px auto; }
|
||||
.narrow { width: min(720px, calc(100% - 32px)); }
|
||||
.topbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
h1 { margin: 0 0 8px; font-size: 28px; }
|
||||
h2 { font-size: 18px; }
|
||||
p { color: #5b6472; }
|
||||
.panel, .filters, .stats, .report-card, .missing {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
form { display: grid; gap: 14px; }
|
||||
label { display: grid; gap: 6px; font-weight: 600; }
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cfd5df;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
button {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.check { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
||||
.stats { display: flex; gap: 20px; margin-bottom: 16px; }
|
||||
.report-list { display: grid; gap: 12px; }
|
||||
.report-card h3 { margin: 0 0 10px; }
|
||||
.report-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.field-title { font-weight: 700; margin-bottom: 4px; }
|
||||
.history-panel { margin-top: 18px; }
|
||||
.history-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.history-list { display: grid; gap: 12px; }
|
||||
.history-card {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.history-card h3 { margin: 0 0 10px; }
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 6px 0 12px;
|
||||
font: inherit;
|
||||
color: #374151;
|
||||
}
|
||||
.message.success { color: #047857; }
|
||||
.message.error { color: #b91c1c; }
|
||||
@media (max-width: 760px) {
|
||||
.topbar, .stats, .history-head { display: grid; }
|
||||
.filters, .report-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
348
daily_report/web.py
Normal file
348
daily_report/web.py
Normal file
@ -0,0 +1,348 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from html import escape
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .config import Config, read_config
|
||||
from .db import Database
|
||||
from .feishu_auth import (
|
||||
authorize_url,
|
||||
create_session_cookie,
|
||||
create_state,
|
||||
exchange_code_for_user_access_token,
|
||||
fetch_user_info,
|
||||
parse_session_cookie,
|
||||
)
|
||||
from .report_service import ReportService
|
||||
from .robot_service import create_reminder_payload, create_summary_payload, send_webhook
|
||||
|
||||
|
||||
def today_string() -> str:
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
def page(title: str, body: str, scripts: str = "") -> bytes:
|
||||
html = f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{escape(title)}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
{body}
|
||||
{scripts}
|
||||
</body>
|
||||
</html>"""
|
||||
return html.encode("utf-8")
|
||||
|
||||
|
||||
def submit_page(current_date: str, session: dict[str, str] | None = None) -> bytes:
|
||||
numbered_template = "1. \n2. \n3. \n4. "
|
||||
if session:
|
||||
identity_html = f"""
|
||||
<div class="identity-box">
|
||||
<span>当前填写人</span>
|
||||
<strong>{escape(session.get("name", session["feishu_user_id"]))}</strong>
|
||||
</div>
|
||||
<input id="feishu-user-id" type="hidden" name="feishu_user_id" value="{escape(session["feishu_user_id"])}">
|
||||
"""
|
||||
history_hint = "系统已自动识别你的飞书身份,可以查看自己最近提交的记录。"
|
||||
else:
|
||||
identity_html = '<label>员工 ID<input id="feishu-user-id" name="feishu_user_id" required placeholder="例如 u_alice"></label>'
|
||||
history_hint = "输入员工 ID 后可以查看自己最近提交的记录。"
|
||||
|
||||
return page(
|
||||
"每日报告",
|
||||
f"""
|
||||
<main class="shell narrow">
|
||||
<h1>每日报告</h1>
|
||||
<form id="report-form" class="panel">
|
||||
{identity_html}
|
||||
<label>日期<input name="report_date" type="date" required value="{escape(current_date)}"></label>
|
||||
<label>今日完成<textarea name="today_done" required rows="5">{numbered_template}</textarea></label>
|
||||
<label>明日计划<textarea name="tomorrow_plan" required rows="4">{numbered_template}</textarea></label>
|
||||
<label>遇到的问题<textarea name="blockers" rows="3"></textarea></label>
|
||||
<label>需要协助<textarea name="help_needed" rows="3"></textarea></label>
|
||||
<button type="submit">提交日报</button>
|
||||
<p id="form-message" class="message"></p>
|
||||
</form>
|
||||
<section class="panel history-panel">
|
||||
<div class="history-head">
|
||||
<div>
|
||||
<h2>我的历史日报</h2>
|
||||
<p>{history_hint}</p>
|
||||
</div>
|
||||
<button id="load-history" type="button">查看历史</button>
|
||||
</div>
|
||||
<div id="history-list" class="history-list">暂无历史记录。</div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
function renderHistory(data) {{
|
||||
const container = document.querySelector("#history-list");
|
||||
if (!data.reports.length) {{
|
||||
container.innerHTML = "<p>还没有历史日报。</p>";
|
||||
return;
|
||||
}}
|
||||
container.innerHTML = data.reports.map((report) => `
|
||||
<article class="history-card">
|
||||
<h3>${{report.report_date}}</h3>
|
||||
<div><strong>今日完成</strong><pre>${{report.today_done}}</pre></div>
|
||||
<div><strong>明日计划</strong><pre>${{report.tomorrow_plan}}</pre></div>
|
||||
<div><strong>问题</strong><pre>${{report.blockers || "无"}}</pre></div>
|
||||
<div><strong>协助</strong><pre>${{report.help_needed || "无"}}</pre></div>
|
||||
</article>
|
||||
`).join("");
|
||||
}}
|
||||
|
||||
document.querySelector("#load-history").addEventListener("click", async () => {{
|
||||
const userId = document.querySelector("#feishu-user-id").value.trim();
|
||||
const container = document.querySelector("#history-list");
|
||||
if (!userId) {{
|
||||
container.textContent = "请先填写员工 ID。";
|
||||
return;
|
||||
}}
|
||||
const response = await fetch(`/api/reports/history?feishu_user_id=${{encodeURIComponent(userId)}}`);
|
||||
if (response.ok) {{
|
||||
renderHistory(await response.json());
|
||||
}} else {{
|
||||
const result = await response.json();
|
||||
container.textContent = result.error || "历史记录加载失败。";
|
||||
}}
|
||||
}});
|
||||
|
||||
document.querySelector("#report-form").addEventListener("submit", async (event) => {{
|
||||
event.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
const response = await fetch("/api/reports", {{
|
||||
method: "POST",
|
||||
headers: {{ "content-type": "application/json" }},
|
||||
body: JSON.stringify(data)
|
||||
}});
|
||||
const message = document.querySelector("#form-message");
|
||||
if (response.ok) {{
|
||||
message.textContent = "提交成功。";
|
||||
message.className = "message success";
|
||||
document.querySelector("#load-history").click();
|
||||
}} else {{
|
||||
const result = await response.json();
|
||||
message.textContent = result.error || "提交失败。";
|
||||
message.className = "message error";
|
||||
}}
|
||||
}});
|
||||
</script>""",
|
||||
)
|
||||
|
||||
|
||||
def manager_page(current_date: str) -> bytes:
|
||||
return page(
|
||||
"日报浏览",
|
||||
f"""
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>日报浏览</h1>
|
||||
<p>按日期、员工和关键词浏览所有日报记录。</p>
|
||||
</div>
|
||||
<button id="copy-summary">复制今日汇总</button>
|
||||
</header>
|
||||
<section class="filters">
|
||||
<input id="date-filter" type="date" value="{escape(current_date)}">
|
||||
<input id="employee-filter" placeholder="筛选员工">
|
||||
<input id="keyword-filter" placeholder="搜索日报内容">
|
||||
<label class="check"><input id="blocker-filter" type="checkbox"> 只看问题/协助</label>
|
||||
<a id="export-link" href="/api/reports/export?date={escape(current_date)}">导出 CSV</a>
|
||||
</section>
|
||||
<section id="stats" class="stats"></section>
|
||||
<section id="reports" class="report-list"></section>
|
||||
<section>
|
||||
<h2>未提交人员</h2>
|
||||
<div id="missing" class="missing"></div>
|
||||
</section>
|
||||
</main>""",
|
||||
'<script src="/static/manager.js"></script>',
|
||||
)
|
||||
|
||||
|
||||
class DailyReportHandler(BaseHTTPRequestHandler):
|
||||
report_service: ReportService
|
||||
config: Config
|
||||
static_dir: Path
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
return
|
||||
|
||||
def _send(self, status: int, body: bytes, content_type: str = "text/html; charset=utf-8") -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("content-type", content_type)
|
||||
self.send_header("content-length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, status: int, data: dict) -> None:
|
||||
self._send(status, json.dumps(data, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
||||
|
||||
def _read_json(self) -> dict:
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
if length == 0:
|
||||
return {}
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def _cookie(self, name: str) -> str | None:
|
||||
for item in self.headers.get("cookie", "").split(";"):
|
||||
if "=" not in item:
|
||||
continue
|
||||
key, value = item.strip().split("=", 1)
|
||||
if key == name:
|
||||
return value
|
||||
return None
|
||||
|
||||
def _session(self) -> dict[str, str] | None:
|
||||
return parse_session_cookie(self._cookie("daily_report_session"), self.config.session_secret)
|
||||
|
||||
def _oauth_enabled(self) -> bool:
|
||||
return bool(self.config.feishu_app_id and self.config.feishu_app_secret)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
query = parse_qs(parsed.query)
|
||||
if parsed.path == "/":
|
||||
self.send_response(302)
|
||||
self.send_header("location", "/manager")
|
||||
self.end_headers()
|
||||
return
|
||||
if parsed.path == "/submit":
|
||||
session = self._session()
|
||||
if self._oauth_enabled() and not session:
|
||||
self.send_response(302)
|
||||
self.send_header("location", "/auth/feishu/start")
|
||||
self.end_headers()
|
||||
return
|
||||
self._send(200, submit_page(today_string(), session))
|
||||
return
|
||||
if parsed.path == "/auth/feishu/start":
|
||||
state = create_state()
|
||||
redirect_uri = f"{self.config.base_url}/auth/feishu/callback"
|
||||
self.send_response(302)
|
||||
self.send_header("set-cookie", f"daily_report_oauth_state={state}; Path=/; HttpOnly; SameSite=Lax")
|
||||
self.send_header("location", authorize_url(self.config.feishu_app_id, redirect_uri, state))
|
||||
self.end_headers()
|
||||
return
|
||||
if parsed.path == "/auth/feishu/callback":
|
||||
state = query.get("state", [""])[0]
|
||||
code = query.get("code", [""])[0]
|
||||
if not code or not state or state != self._cookie("daily_report_oauth_state"):
|
||||
self._json(400, {"error": "invalid feishu oauth callback"})
|
||||
return
|
||||
redirect_uri = f"{self.config.base_url}/auth/feishu/callback"
|
||||
token = exchange_code_for_user_access_token(
|
||||
self.config.feishu_app_id,
|
||||
self.config.feishu_app_secret,
|
||||
code,
|
||||
redirect_uri,
|
||||
)
|
||||
employee = fetch_user_info(token)
|
||||
self.report_service.database.upsert_employee(employee)
|
||||
cookie = create_session_cookie(
|
||||
{"feishu_user_id": employee["feishu_user_id"], "name": employee["name"]},
|
||||
self.config.session_secret,
|
||||
)
|
||||
self.send_response(302)
|
||||
self.send_header("set-cookie", f"daily_report_session={cookie}; Path=/; HttpOnly; SameSite=Lax")
|
||||
self.send_header("set-cookie", "daily_report_oauth_state=; Path=/; Max-Age=0")
|
||||
self.send_header("location", "/submit")
|
||||
self.end_headers()
|
||||
return
|
||||
if parsed.path == "/manager":
|
||||
self._send(200, manager_page(query.get("date", [today_string()])[0]))
|
||||
return
|
||||
if parsed.path == "/api/reports":
|
||||
self._json(200, self.report_service.list_reports_for_date(query.get("date", [today_string()])[0]))
|
||||
return
|
||||
if parsed.path == "/api/reports/history":
|
||||
user_id = query.get("feishu_user_id", [""])[0]
|
||||
try:
|
||||
self._json(200, self.report_service.list_employee_history(user_id))
|
||||
except ValueError as error:
|
||||
self._json(400, {"error": str(error)})
|
||||
return
|
||||
if parsed.path == "/api/reports/export":
|
||||
report_date = query.get("date", [today_string()])[0]
|
||||
csv_text = self.report_service.export_reports_csv(report_date)
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "text/csv; charset=utf-8")
|
||||
self.send_header("content-disposition", f'attachment; filename="daily-reports-{report_date}.csv"')
|
||||
self.end_headers()
|
||||
self.wfile.write(csv_text.encode("utf-8-sig"))
|
||||
return
|
||||
if parsed.path.startswith("/static/"):
|
||||
self._serve_static(parsed.path.removeprefix("/static/"))
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
try:
|
||||
if parsed.path == "/api/reports":
|
||||
self._json(200, {"report": self.report_service.upsert_report(self._read_json())})
|
||||
return
|
||||
if parsed.path == "/api/robot/send-reminder":
|
||||
payload = create_reminder_payload(f"{self.config.base_url}/submit")
|
||||
self._json(200, {"ok": True, "result": send_webhook(self.config.feishu_webhook_url, payload)})
|
||||
return
|
||||
if parsed.path == "/api/robot/send-summary":
|
||||
body = self._read_json()
|
||||
report_date = body.get("date") or today_string()
|
||||
summary = self.report_service.list_reports_for_date(report_date)
|
||||
payload = create_summary_payload(f"{self.config.base_url}/manager?date={report_date}", summary)
|
||||
self._json(200, {"ok": True, "result": send_webhook(self.config.feishu_webhook_url, payload)})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
except (ValueError, json.JSONDecodeError) as error:
|
||||
self._json(400, {"error": str(error)})
|
||||
except Exception as error:
|
||||
self._json(500, {"error": str(error)})
|
||||
|
||||
def _serve_static(self, name: str) -> None:
|
||||
safe_name = name.replace("\\", "/").split("/")[-1]
|
||||
file_path = self.static_dir / safe_name
|
||||
if not file_path.exists():
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
content_type = "application/javascript; charset=utf-8" if file_path.suffix == ".js" else "text/css; charset=utf-8"
|
||||
self._send(200, file_path.read_bytes(), content_type)
|
||||
|
||||
|
||||
def create_server(config: Config, database: Database | None = None) -> ThreadingHTTPServer:
|
||||
db = database or Database(config.database_path, config.employee_seed_path)
|
||||
service = ReportService(db)
|
||||
|
||||
class Handler(DailyReportHandler):
|
||||
report_service = service
|
||||
static_dir = config.root_dir / "daily_report" / "static"
|
||||
|
||||
Handler.config = config
|
||||
server = ThreadingHTTPServer(("", config.port), Handler)
|
||||
server.database = db # type: ignore[attr-defined]
|
||||
return server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = read_config()
|
||||
server = create_server(config)
|
||||
print(f"Daily report app listening on {config.base_url}")
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
server.server_close()
|
||||
server.database.close() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
21
daily_report/workday.py
Normal file
21
daily_report/workday.py
Normal file
@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_workday(selected_date: date, calendar_path: Path) -> bool:
|
||||
if not calendar_path.exists():
|
||||
return selected_date.weekday() < 5
|
||||
|
||||
calendar = json.loads(calendar_path.read_text(encoding="utf-8"))
|
||||
day = selected_date.isoformat()
|
||||
holidays = set(calendar.get("holidays", []))
|
||||
makeup_workdays = set(calendar.get("makeup_workdays", []))
|
||||
|
||||
if day in makeup_workdays:
|
||||
return True
|
||||
if day in holidays:
|
||||
return False
|
||||
return selected_date.weekday() < 5
|
||||
16
data/employees.json
Normal file
16
data/employees.json
Normal file
@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"feishu_user_id": "u_alice",
|
||||
"name": "Alice",
|
||||
"department": "Operations",
|
||||
"manager": "Manager",
|
||||
"active": true
|
||||
},
|
||||
{
|
||||
"feishu_user_id": "u_bob",
|
||||
"name": "Bob",
|
||||
"department": "Operations",
|
||||
"manager": "Manager",
|
||||
"active": true
|
||||
}
|
||||
]
|
||||
47
data/workday-calendar.json
Normal file
47
data/workday-calendar.json
Normal file
@ -0,0 +1,47 @@
|
||||
{
|
||||
"year": 2026,
|
||||
"source": "https://www.gov.cn/yaowen/liebiao/202511/content_7047099.htm",
|
||||
"holidays": [
|
||||
"2026-01-01",
|
||||
"2026-01-02",
|
||||
"2026-01-03",
|
||||
"2026-02-15",
|
||||
"2026-02-16",
|
||||
"2026-02-17",
|
||||
"2026-02-18",
|
||||
"2026-02-19",
|
||||
"2026-02-20",
|
||||
"2026-02-21",
|
||||
"2026-02-22",
|
||||
"2026-02-23",
|
||||
"2026-04-04",
|
||||
"2026-04-05",
|
||||
"2026-04-06",
|
||||
"2026-05-01",
|
||||
"2026-05-02",
|
||||
"2026-05-03",
|
||||
"2026-05-04",
|
||||
"2026-05-05",
|
||||
"2026-06-19",
|
||||
"2026-06-20",
|
||||
"2026-06-21",
|
||||
"2026-09-25",
|
||||
"2026-09-26",
|
||||
"2026-09-27",
|
||||
"2026-10-01",
|
||||
"2026-10-02",
|
||||
"2026-10-03",
|
||||
"2026-10-04",
|
||||
"2026-10-05",
|
||||
"2026-10-06",
|
||||
"2026-10-07"
|
||||
],
|
||||
"makeup_workdays": [
|
||||
"2026-01-04",
|
||||
"2026-02-14",
|
||||
"2026-02-28",
|
||||
"2026-05-09",
|
||||
"2026-09-20",
|
||||
"2026-10-10"
|
||||
]
|
||||
}
|
||||
139
docs/superpowers/plans/2026-05-07-feishu-daily-report.md
Normal file
139
docs/superpowers/plans/2026-05-07-feishu-daily-report.md
Normal file
@ -0,0 +1,139 @@
|
||||
# 飞书每日工作汇报系统实施计划
|
||||
|
||||
> **给执行代理的要求:** 实施本计划时必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`。所有步骤使用复选框语法,便于逐项跟踪。
|
||||
|
||||
**目标:** 构建第一版飞书日报 Web 应用,让员工提交每日报告,让管理者在一个页面浏览所有日报记录。
|
||||
|
||||
**架构:** 使用 Python 标准库实现一个零第三方依赖的小型 Web 应用。后端使用 `http.server` 提供页面和 API,使用 `sqlite3` 保存员工与日报数据,使用 `urllib.request` 调用飞书机器人 Webhook。前端使用原生 HTML/CSS/JavaScript。
|
||||
|
||||
**技术栈:** Python 标准库、`http.server`、`sqlite3`、`unittest`、原生 HTML/CSS/JavaScript、飞书自定义机器人 Webhook。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
- `requirements.txt`:说明项目只使用 Python 标准库。
|
||||
- `.env.example`:环境变量示例。
|
||||
- `.gitignore`:忽略本地数据库、环境变量和缓存文件。
|
||||
- `data/employees.json`:第一版员工名单种子文件。
|
||||
- `daily_report/config.py`:读取环境变量和 `.env` 配置。
|
||||
- `daily_report/db.py`:SQLite 建表、员工导入和基础查询。
|
||||
- `daily_report/report_service.py`:日报校验、创建/更新、列表、未提交人员和 CSV 导出。
|
||||
- `daily_report/robot_service.py`:飞书机器人提醒/汇总消息体和 Webhook 发送。
|
||||
- `daily_report/web.py`:HTTP 页面路由、API 路由和静态文件服务。
|
||||
- `daily_report/static/styles.css`:提交页和管理页样式。
|
||||
- `daily_report/static/manager.js`:管理页筛选、复制汇总和数据加载逻辑。
|
||||
- `tests/test_report_service.py`:日报业务测试。
|
||||
- `tests/test_robot_service.py`:机器人消息体测试。
|
||||
- `tests/test_web.py`:页面和 API 冒烟测试。
|
||||
- `README.md`:中文使用说明。
|
||||
|
||||
## 任务 1:项目骨架
|
||||
|
||||
- [x] 创建 Python 包 `daily_report`。
|
||||
- [x] 创建 `requirements.txt`,说明无需第三方依赖。
|
||||
- [x] 创建 `.gitignore`。
|
||||
- [x] 创建 `.env.example`。
|
||||
- [x] 创建 `data/employees.json` 示例员工名单。
|
||||
- [x] 实现 `daily_report/config.py`,读取端口、基础 URL、数据库路径、员工名单路径和飞书 Webhook 配置。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
python -m py_compile daily_report/config.py
|
||||
```
|
||||
|
||||
## 任务 2:数据层和日报服务
|
||||
|
||||
- [x] 实现 `daily_report/db.py`。
|
||||
- [x] 创建 `employees` 表。
|
||||
- [x] 创建 `daily_reports` 表。
|
||||
- [x] 导入 `data/employees.json` 中的有效员工。
|
||||
- [x] 实现同一员工同一天唯一日报。
|
||||
- [x] 实现 `daily_report/report_service.py`。
|
||||
- [x] 校验必填字段:员工 ID、日期、今日完成、明日计划。
|
||||
- [x] 重复提交时更新当天已有日报。
|
||||
- [x] 计算已提交、未提交和应提交人数。
|
||||
- [x] 导出 CSV。
|
||||
- [x] 编写 `tests/test_report_service.py`。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_report_service
|
||||
```
|
||||
|
||||
## 任务 3:飞书机器人服务
|
||||
|
||||
- [x] 实现 `daily_report/robot_service.py`。
|
||||
- [x] 生成“填写日报”提醒消息体。
|
||||
- [x] 生成“日报提交汇总”消息体。
|
||||
- [x] 支持调用飞书自定义机器人 Webhook。
|
||||
- [x] 编写 `tests/test_robot_service.py`。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_robot_service
|
||||
```
|
||||
|
||||
## 任务 4:页面和 API
|
||||
|
||||
- [x] 实现 `daily_report/web.py`。
|
||||
- [x] 提供 `/submit` 员工日报提交页。
|
||||
- [x] 提供 `/manager` 管理者日报浏览页。
|
||||
- [x] 提供 `POST /api/reports` 创建或更新日报。
|
||||
- [x] 提供 `GET /api/reports?date=YYYY-MM-DD` 查询某日汇总。
|
||||
- [x] 提供 `GET /api/reports/export?date=YYYY-MM-DD` 导出 CSV。
|
||||
- [x] 提供 `POST /api/robot/send-reminder` 发送飞书提醒。
|
||||
- [x] 提供 `POST /api/robot/send-summary` 发送飞书汇总。
|
||||
- [x] 实现 `daily_report/static/styles.css`。
|
||||
- [x] 实现 `daily_report/static/manager.js`。
|
||||
- [x] 编写 `tests/test_web.py`。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
python -m unittest tests.test_web
|
||||
```
|
||||
|
||||
## 任务 5:文档和手动验证
|
||||
|
||||
- [x] 编写中文 `README.md`。
|
||||
- [x] 更新设计文档中的实际技术方案。
|
||||
- [x] 启动本地服务。
|
||||
- [x] 验证管理页可以访问。
|
||||
- [x] 验证日报提交 API 可以写入数据。
|
||||
- [x] 验证日报汇总 API 可以读取数据。
|
||||
|
||||
启动:
|
||||
|
||||
```bash
|
||||
python -m daily_report.web
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- 提交页:http://localhost:8787/submit
|
||||
- 管理页:http://localhost:8787/manager
|
||||
|
||||
## 最终验证
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests
|
||||
python -m py_compile daily_report/config.py daily_report/db.py daily_report/report_service.py daily_report/robot_service.py daily_report/web.py
|
||||
```
|
||||
|
||||
当前验证结果:
|
||||
|
||||
- `python -m unittest discover -s tests`:8 个测试通过。
|
||||
- `python -m py_compile ...`:通过。
|
||||
- `http://localhost:8787/manager`:返回 200。
|
||||
|
||||
## 后续增强
|
||||
|
||||
- 接入飞书 OAuth,自动识别员工身份。
|
||||
- 增加管理者登录和权限控制。
|
||||
- 增加飞书多维表格同步。
|
||||
- 增加定时任务,让提醒和汇总自动发送。
|
||||
- 增加员工名单管理页面,减少手工编辑 JSON。
|
||||
212
docs/superpowers/specs/2026-05-07-feishu-daily-report-design.md
Normal file
212
docs/superpowers/specs/2026-05-07-feishu-daily-report-design.md
Normal file
@ -0,0 +1,212 @@
|
||||
# 飞书每日工作汇报系统设计
|
||||
|
||||
## 目标
|
||||
|
||||
建设一个轻量、实用的飞书每日工作汇报系统。员工每天通过飞书机器人提醒进入填写页面提交日报,管理者可以在一个内部浏览页面查看所有人的日报记录。
|
||||
|
||||
第一版优先保证快速上线、流程顺畅、后续容易扩展。核心能力包括:每日提醒、员工填写、数据保存、管理者统一浏览、未提交统计和每日汇总。
|
||||
|
||||
## 使用角色
|
||||
|
||||
- 员工:每天提交一份工作日报。
|
||||
- 管理者:查看日报、检查未提交人员、复制或导出汇总。
|
||||
- 管理员:维护员工名单和飞书机器人配置。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
采用“飞书机器人 + 小型 Web 应用”的方案。
|
||||
|
||||
- 飞书机器人每天在指定群发送日报提醒。
|
||||
- 提醒消息里包含“填写日报”的按钮或链接。
|
||||
- 员工打开填写页面并提交日报。
|
||||
- 管理者打开浏览页面,在一个页面里查看所有记录。
|
||||
- 日报数据由应用保存,后续可以按需同步到飞书多维表格。
|
||||
|
||||
这个方案比只使用多维表格更适合阅读和管理,也比完整飞书自建应用更容易启动。
|
||||
|
||||
## 日报字段
|
||||
|
||||
第一版采用标准四段式日报模板:
|
||||
|
||||
- 员工姓名和飞书用户 ID
|
||||
- 汇报日期
|
||||
- 今日完成
|
||||
- 明日计划
|
||||
- 遇到的问题或阻塞
|
||||
- 需要协助的事项
|
||||
- 提交时间
|
||||
- 更新时间
|
||||
- 提交状态
|
||||
|
||||
必填字段:
|
||||
|
||||
- 员工身份
|
||||
- 汇报日期
|
||||
- 今日完成
|
||||
- 明日计划
|
||||
|
||||
选填字段:
|
||||
|
||||
- 遇到的问题或阻塞
|
||||
- 需要协助的事项
|
||||
|
||||
如果员工当天重复提交,系统更新当天已有记录,不额外创建重复日报。
|
||||
|
||||
## 飞书流程
|
||||
|
||||
1. 到达配置的提醒时间,例如 18:00,机器人在工作汇报群发送日报提醒。
|
||||
2. 员工点击提醒消息中的“填写日报”。
|
||||
3. 系统打开日报填写页面,并默认选择当天日期。
|
||||
4. 员工填写四段式日报并提交。
|
||||
5. 后端校验并保存日报。
|
||||
6. 页面显示提交成功状态。
|
||||
7. 到达配置的汇总时间,例如 19:00,机器人在群里发送完成情况汇总:
|
||||
- 应提交人数
|
||||
- 已提交人数
|
||||
- 未提交人员名单
|
||||
- 管理者浏览页面链接
|
||||
|
||||
第一版可以先使用带签名的链接或简单的员工选择方式识别员工。后续版本再接入飞书 OAuth,自动识别当前登录用户。
|
||||
|
||||
## 管理者浏览页面
|
||||
|
||||
管理者浏览页是日常查看日报的主工作台,打开后默认展示当天日报。
|
||||
|
||||
筛选控件:
|
||||
|
||||
- 日期选择
|
||||
- 员工筛选
|
||||
- 部门筛选,如果已配置部门
|
||||
- 关键词搜索
|
||||
- 只看未提交
|
||||
- 只看有问题或需要协助的日报
|
||||
|
||||
主要内容:
|
||||
|
||||
- 当前日期的提交统计
|
||||
- 按员工分组展示已提交日报
|
||||
- 未提交人员名单
|
||||
- 每条日报展示今日完成、明日计划、问题、协助需求和提交时间
|
||||
|
||||
管理操作:
|
||||
|
||||
- 复制今日汇总
|
||||
- 导出 CSV
|
||||
- 打开原始数据表
|
||||
|
||||
页面应优先保证阅读体验,而不是做成纯表格。管理者应该能在一个页面里快速扫完当天所有日报。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### employees
|
||||
|
||||
- id
|
||||
- feishu_user_id
|
||||
- name
|
||||
- department
|
||||
- manager
|
||||
- active
|
||||
- created_at
|
||||
- updated_at
|
||||
|
||||
### daily_reports
|
||||
|
||||
- id
|
||||
- feishu_user_id
|
||||
- employee_name
|
||||
- report_date
|
||||
- today_done
|
||||
- tomorrow_plan
|
||||
- blockers
|
||||
- help_needed
|
||||
- submitted_at
|
||||
- updated_at
|
||||
|
||||
唯一约束:
|
||||
|
||||
- 同一个 `feishu_user_id` 在同一个 `report_date` 只能有一条日报
|
||||
|
||||
## API 设计
|
||||
|
||||
### 员工接口
|
||||
|
||||
- `GET /submit`
|
||||
- 展示日报填写页面。
|
||||
|
||||
- `POST /api/reports`
|
||||
- 创建或更新某员工某日期的日报。
|
||||
|
||||
### 管理者接口
|
||||
|
||||
- `GET /manager`
|
||||
- 展示日报浏览页面。
|
||||
|
||||
- `GET /api/reports?date=YYYY-MM-DD`
|
||||
- 返回指定日期的已提交日报和未提交人员。
|
||||
|
||||
- `GET /api/reports/export?date=YYYY-MM-DD`
|
||||
- 导出指定日期的日报 CSV。
|
||||
|
||||
### 机器人接口
|
||||
|
||||
- `POST /api/robot/send-reminder`
|
||||
- 向飞书群发送日报提醒。
|
||||
|
||||
- `POST /api/robot/send-summary`
|
||||
- 向飞书群发送日报完成情况汇总。
|
||||
|
||||
这些接口第一版可以由本地定时任务或手动脚本调用。
|
||||
|
||||
## 技术方案
|
||||
|
||||
第一版实际技术栈:
|
||||
|
||||
- 后端:Python 标准库 `http.server`
|
||||
- 前端:原生 HTML/CSS/JavaScript
|
||||
- 数据库:SQLite
|
||||
- 飞书集成:飞书自定义机器人 Webhook,用于发送提醒和汇总
|
||||
|
||||
Python 标准库方案不依赖 npm 或第三方包,适合当前本地环境快速运行。SQLite 足够支撑第一版,并且部署简单。如果后续多人并发或权限要求增强,可以迁移到 PostgreSQL,用户流程不需要变化。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 必填字段缺失时,在表单内展示校验错误。
|
||||
- 当天重复提交时,更新已有日报。
|
||||
- 机器人发送失败时记录日志,并支持手动重试。
|
||||
- 管理页在指定日期没有日报时展示空状态。
|
||||
- 无法识别员工身份时,提交页要求员工从有效员工名单中选择姓名。
|
||||
|
||||
## 安全设计
|
||||
|
||||
第一版:
|
||||
|
||||
- 使用难以猜测的提交链接,或仅在公司内部网络/内部成员范围内访问。
|
||||
- 管理页面只在内部使用。
|
||||
- 飞书 Webhook 密钥不写入源代码。
|
||||
|
||||
后续版本:
|
||||
|
||||
- 接入飞书 OAuth 自动识别员工身份。
|
||||
- 增加管理者权限控制。
|
||||
- 增加日报编辑审计日志。
|
||||
|
||||
## 测试范围
|
||||
|
||||
核心检查项:
|
||||
|
||||
- 可以提交一份完整日报。
|
||||
- 缺少必填字段时拒绝提交。
|
||||
- 同一天重复提交会更新原记录。
|
||||
- 管理页能展示某日期的已提交和未提交人员。
|
||||
- 管理页能按员工和关键词筛选。
|
||||
- 可以导出 CSV。
|
||||
- 可以生成提醒消息和汇总消息。
|
||||
|
||||
## 里程碑
|
||||
|
||||
1. 搭建本地数据模型和日报提交流程。
|
||||
2. 搭建管理者浏览页面。
|
||||
3. 通过种子文件或简单配置维护员工名单。
|
||||
4. 接入飞书机器人提醒和汇总发送。
|
||||
5. 增加 CSV 导出和复制汇总功能。
|
||||
6. 可选:接入飞书 OAuth 和多维表格同步。
|
||||
20
install-tasks.bat
Normal file
20
install-tasks.bat
Normal file
@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
set "LOG=%~dp0install-tasks.log"
|
||||
|
||||
echo Installing Windows scheduled tasks...
|
||||
echo Installing Windows scheduled tasks... > "%LOG%"
|
||||
echo Project: %CD% >> "%LOG%"
|
||||
echo. >> "%LOG%"
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\install-windows-tasks.ps1" >> "%LOG%" 2>&1
|
||||
type "%LOG%"
|
||||
|
||||
echo.
|
||||
echo If there is no error above, tasks are installed:
|
||||
echo - FeishuDailyReport-Reminder runs daily at 18:00 and skips holidays
|
||||
echo - FeishuDailyReport-Summary runs daily at 19:00 and skips holidays
|
||||
echo.
|
||||
echo If there is an error, send Codex a screenshot.
|
||||
pause
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
# This project uses only the Python standard library.
|
||||
48
scripts/install-windows-tasks.ps1
Normal file
48
scripts/install-windows-tasks.ps1
Normal file
@ -0,0 +1,48 @@
|
||||
param(
|
||||
[string]$PythonPath = "C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe",
|
||||
[string]$TaskPrefix = "FeishuDailyReport",
|
||||
[string]$ReminderTime = "18:00",
|
||||
[string]$SummaryTime = "19:00"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
|
||||
|
||||
if (-not (Test-Path $PythonPath)) {
|
||||
throw "Python not found: $PythonPath"
|
||||
}
|
||||
|
||||
$reminderAction = New-ScheduledTaskAction `
|
||||
-Execute $PythonPath `
|
||||
-Argument "-m daily_report.scheduled reminder" `
|
||||
-WorkingDirectory $ProjectRoot
|
||||
|
||||
$summaryAction = New-ScheduledTaskAction `
|
||||
-Execute $PythonPath `
|
||||
-Argument "-m daily_report.scheduled summary" `
|
||||
-WorkingDirectory $ProjectRoot
|
||||
|
||||
$reminderTrigger = New-ScheduledTaskTrigger -Daily -At $ReminderTime
|
||||
$summaryTrigger = New-ScheduledTaskTrigger -Daily -At $SummaryTime
|
||||
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName "$TaskPrefix-Reminder" `
|
||||
-Action $reminderAction `
|
||||
-Trigger $reminderTrigger `
|
||||
-Settings $settings `
|
||||
-Description "Runs daily at $ReminderTime; script sends only on configured workdays" `
|
||||
-Force | Out-Null
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName "$TaskPrefix-Summary" `
|
||||
-Action $summaryAction `
|
||||
-Trigger $summaryTrigger `
|
||||
-Settings $settings `
|
||||
-Description "Runs daily at $SummaryTime; script sends only on configured workdays" `
|
||||
-Force | Out-Null
|
||||
|
||||
Write-Host "Windows scheduled tasks installed:"
|
||||
Write-Host " - $TaskPrefix-Reminder: runs daily at $ReminderTime and skips non-workdays"
|
||||
Write-Host " - $TaskPrefix-Summary: runs daily at $SummaryTime and skips non-workdays"
|
||||
Write-Host "Project root: $ProjectRoot"
|
||||
15
scripts/uninstall-windows-tasks.ps1
Normal file
15
scripts/uninstall-windows-tasks.ps1
Normal file
@ -0,0 +1,15 @@
|
||||
param(
|
||||
[string]$TaskPrefix = "FeishuDailyReport"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
foreach ($name in @("$TaskPrefix-Reminder", "$TaskPrefix-Summary")) {
|
||||
$task = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
|
||||
if ($task) {
|
||||
Unregister-ScheduledTask -TaskName $name -Confirm:$false
|
||||
Write-Host "Removed task: $name"
|
||||
} else {
|
||||
Write-Host "Task not found: $name"
|
||||
}
|
||||
}
|
||||
26
send-reminder.bat
Normal file
26
send-reminder.bat
Normal file
@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||
set "LOG=%~dp0send-reminder.log"
|
||||
|
||||
echo Sending Feishu reminder...
|
||||
echo Sending Feishu reminder... > "%LOG%"
|
||||
echo Project: %CD% >> "%LOG%"
|
||||
echo Python: %PY% >> "%LOG%"
|
||||
echo. >> "%LOG%"
|
||||
|
||||
if not exist "%PY%" (
|
||||
echo Python not found: %PY%
|
||||
echo Python not found: %PY% >> "%LOG%"
|
||||
goto END
|
||||
)
|
||||
|
||||
"%PY%" -m daily_report.scheduled reminder >> "%LOG%" 2>&1
|
||||
type "%LOG%"
|
||||
|
||||
:END
|
||||
echo.
|
||||
echo Log file: %LOG%
|
||||
echo Please check the Feishu group. If there is an error, send Codex a screenshot.
|
||||
pause
|
||||
26
send-summary.bat
Normal file
26
send-summary.bat
Normal file
@ -0,0 +1,26 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||
set "LOG=%~dp0send-summary.log"
|
||||
|
||||
echo Sending Feishu summary...
|
||||
echo Sending Feishu summary... > "%LOG%"
|
||||
echo Project: %CD% >> "%LOG%"
|
||||
echo Python: %PY% >> "%LOG%"
|
||||
echo. >> "%LOG%"
|
||||
|
||||
if not exist "%PY%" (
|
||||
echo Python not found: %PY%
|
||||
echo Python not found: %PY% >> "%LOG%"
|
||||
goto END
|
||||
)
|
||||
|
||||
"%PY%" -m daily_report.scheduled summary >> "%LOG%" 2>&1
|
||||
type "%LOG%"
|
||||
|
||||
:END
|
||||
echo.
|
||||
echo Log file: %LOG%
|
||||
echo Please check the Feishu group. If there is an error, send Codex a screenshot.
|
||||
pause
|
||||
20
start-server.bat
Normal file
20
start-server.bat
Normal file
@ -0,0 +1,20 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
set "PY=C:\Users\13636\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe"
|
||||
|
||||
echo Starting Feishu daily report server...
|
||||
echo Project: %CD%
|
||||
echo.
|
||||
|
||||
if not exist "%PY%" (
|
||||
echo Python not found: %PY%
|
||||
echo Send Codex a screenshot.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
"%PY%" -m daily_report.web
|
||||
echo.
|
||||
echo Server stopped.
|
||||
pause
|
||||
42
tests/test_config.py
Normal file
42
tests/test_config.py
Normal file
@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily_report import config
|
||||
|
||||
|
||||
class ConfigTest(unittest.TestCase):
|
||||
def test_reads_dotenv_and_allows_environment_override(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="daily-report-config-") as temp:
|
||||
root = Path(temp)
|
||||
(root / ".env").write_text(
|
||||
"\n".join(
|
||||
[
|
||||
"PORT=9000",
|
||||
"BASE_URL=http://example.test",
|
||||
"FEISHU_WEBHOOK_URL=https://open.feishu.cn/webhook",
|
||||
"FEISHU_APP_ID=cli_xxx",
|
||||
"FEISHU_APP_SECRET=secret",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_file = root / "daily_report" / "config.py"
|
||||
fake_file.parent.mkdir()
|
||||
fake_file.write_text("", encoding="utf-8")
|
||||
|
||||
with patch.object(config, "__file__", str(fake_file)):
|
||||
result = config.read_config({"PORT": "9001"})
|
||||
|
||||
self.assertEqual(result.port, 9001)
|
||||
self.assertEqual(result.base_url, "http://example.test")
|
||||
self.assertEqual(result.feishu_webhook_url, "https://open.feishu.cn/webhook")
|
||||
self.assertEqual(result.feishu_app_id, "cli_xxx")
|
||||
self.assertEqual(result.feishu_app_secret, "secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
19
tests/test_feishu_auth.py
Normal file
19
tests/test_feishu_auth.py
Normal file
@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from daily_report.feishu_auth import create_session_cookie, parse_session_cookie
|
||||
|
||||
|
||||
class FeishuAuthTest(unittest.TestCase):
|
||||
def test_session_cookie_round_trip_and_rejects_tampering(self) -> None:
|
||||
cookie = create_session_cookie({"feishu_user_id": "ou_1", "name": "张三"}, "secret")
|
||||
session = parse_session_cookie(cookie, "secret")
|
||||
|
||||
self.assertEqual(session["feishu_user_id"], "ou_1")
|
||||
self.assertEqual(session["name"], "张三")
|
||||
self.assertIsNone(parse_session_cookie(cookie + "x", "secret"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
110
tests/test_report_service.py
Normal file
110
tests/test_report_service.py
Normal file
@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from daily_report.db import Database
|
||||
from daily_report.report_service import ReportService
|
||||
|
||||
|
||||
def make_database(employees: list[dict]) -> Database:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="daily-report-"))
|
||||
seed_path = temp_dir / "employees.json"
|
||||
seed_path.write_text(json.dumps(employees), encoding="utf-8")
|
||||
return Database(temp_dir / "test.sqlite", seed_path)
|
||||
|
||||
|
||||
class ReportServiceTest(unittest.TestCase):
|
||||
def test_creates_schema_and_loads_active_employees(self) -> None:
|
||||
db = make_database(
|
||||
[
|
||||
{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "manager": "Mo", "active": True},
|
||||
{"feishu_user_id": "u_2", "name": "Chen", "department": "Sales", "manager": "Mo", "active": False},
|
||||
]
|
||||
)
|
||||
try:
|
||||
employees = db.list_active_employees()
|
||||
self.assertEqual(len(employees), 1)
|
||||
self.assertEqual(employees[0]["name"], "Lin")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_validates_required_report_fields(self) -> None:
|
||||
db = make_database([{"feishu_user_id": "u_1", "name": "Lin", "active": True}])
|
||||
service = ReportService(db)
|
||||
try:
|
||||
with self.assertRaisesRegex(ValueError, "today_done is required"):
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "",
|
||||
"tomorrow_plan": "完成管理页",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_upserts_one_report_per_employee_and_date(self) -> None:
|
||||
db = make_database([{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}])
|
||||
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
|
||||
try:
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成原型初稿",
|
||||
"tomorrow_plan": "和主管评审",
|
||||
}
|
||||
)
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成原型初稿并调整文案",
|
||||
"tomorrow_plan": "和主管评审",
|
||||
"blockers": "需要确认 logo",
|
||||
"help_needed": "确认视觉方向",
|
||||
}
|
||||
)
|
||||
result = service.list_reports_for_date("2026-05-07")
|
||||
self.assertEqual(len(result["reports"]), 1)
|
||||
self.assertEqual(result["reports"][0]["today_done"], "完成原型初稿并调整文案")
|
||||
self.assertEqual(result["reports"][0]["blockers"], "需要确认 logo")
|
||||
self.assertEqual(len(result["missing"]), 0)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def test_returns_missing_active_employees_and_csv_export(self) -> None:
|
||||
db = make_database(
|
||||
[
|
||||
{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True},
|
||||
{"feishu_user_id": "u_2", "name": "Chen", "department": "Sales", "active": True},
|
||||
]
|
||||
)
|
||||
service = ReportService(db, clock=lambda: datetime(2026, 5, 7, 10, tzinfo=timezone.utc))
|
||||
try:
|
||||
service.upsert_report(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成上线检查清单",
|
||||
"tomorrow_plan": "准备发布",
|
||||
}
|
||||
)
|
||||
result = service.list_reports_for_date("2026-05-07")
|
||||
self.assertEqual(result["submittedCount"], 1)
|
||||
self.assertEqual(result["missing"][0]["name"], "Chen")
|
||||
|
||||
csv_text = service.export_reports_csv("2026-05-07")
|
||||
self.assertIn("employee_name,report_date,today_done,tomorrow_plan,blockers,help_needed,submitted_at", csv_text)
|
||||
self.assertIn("Lin,2026-05-07,完成上线检查清单,准备发布", csv_text)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
38
tests/test_robot_service.py
Normal file
38
tests/test_robot_service.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from daily_report.robot_service import create_reminder_payload, create_summary_payload
|
||||
|
||||
|
||||
class RobotServiceTest(unittest.TestCase):
|
||||
def test_creates_reminder_payload_with_submit_link(self) -> None:
|
||||
payload = create_reminder_payload("http://localhost:8787/submit", "每日工作汇报提醒")
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
self.assertEqual(payload["msg_type"], "interactive")
|
||||
self.assertIn("每日工作汇报提醒", text)
|
||||
self.assertIn("http://localhost:8787/submit", text)
|
||||
|
||||
def test_creates_summary_payload_with_missing_employees(self) -> None:
|
||||
payload = create_summary_payload(
|
||||
"http://localhost:8787/manager",
|
||||
{
|
||||
"date": "2026-05-07",
|
||||
"expectedCount": 2,
|
||||
"submittedCount": 1,
|
||||
"missingCount": 1,
|
||||
"missing": [{"name": "Chen"}],
|
||||
},
|
||||
)
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
self.assertIn("2026-05-07", text)
|
||||
self.assertIn("1/2", text)
|
||||
self.assertIn("Chen", text)
|
||||
self.assertIn("http://localhost:8787/manager", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
58
tests/test_scheduled.py
Normal file
58
tests/test_scheduled.py
Normal file
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from daily_report import scheduled
|
||||
|
||||
|
||||
class ScheduledTest(unittest.TestCase):
|
||||
def test_send_summary_builds_today_summary_payload(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="daily-report-scheduled-") as temp:
|
||||
root = Path(temp)
|
||||
seed_path = root / "employees.json"
|
||||
seed_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"feishu_user_id": "u_1", "name": "Lin", "active": True},
|
||||
{"feishu_user_id": "u_2", "name": "Chen", "active": True},
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake_file = root / "daily_report" / "config.py"
|
||||
fake_file.parent.mkdir()
|
||||
fake_file.write_text("", encoding="utf-8")
|
||||
env = {
|
||||
"DATABASE_PATH": "test.sqlite",
|
||||
"EMPLOYEE_SEED_PATH": "employees.json",
|
||||
"BASE_URL": "http://localhost:8787",
|
||||
"FEISHU_WEBHOOK_URL": "https://example.test/webhook",
|
||||
}
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_send(webhook_url: str, payload: dict) -> dict:
|
||||
captured["webhook_url"] = webhook_url
|
||||
captured["payload"] = payload
|
||||
return {"ok": True}
|
||||
|
||||
with patch("daily_report.config.__file__", str(fake_file)), patch.dict("os.environ", env, clear=True), patch(
|
||||
"daily_report.scheduled.send_webhook", fake_send
|
||||
):
|
||||
result = scheduled.send_summary("2026-05-07")
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
self.assertEqual(captured["webhook_url"], "https://example.test/webhook")
|
||||
text = json.dumps(captured["payload"], ensure_ascii=False)
|
||||
self.assertIn("2026-05-07 日报提交汇总", text)
|
||||
self.assertIn("0/2", text)
|
||||
self.assertIn("Chen、Lin", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
127
tests/test_web.py
Normal file
127
tests/test_web.py
Normal file
@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from daily_report.config import Config
|
||||
from daily_report.db import Database
|
||||
from daily_report.feishu_auth import create_session_cookie
|
||||
from daily_report.web import create_server
|
||||
|
||||
|
||||
def make_server() -> tuple:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="daily-report-web-"))
|
||||
seed_path = temp_dir / "employees.json"
|
||||
seed_path.write_text(
|
||||
json.dumps([{"feishu_user_id": "u_1", "name": "Lin", "department": "Design", "active": True}]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
config = Config(
|
||||
root_dir=Path(__file__).resolve().parent.parent,
|
||||
port=0,
|
||||
base_url="http://localhost:8787",
|
||||
database_path=temp_dir / "test.sqlite",
|
||||
employee_seed_path=seed_path,
|
||||
workday_calendar_path=temp_dir / "workday-calendar.json",
|
||||
feishu_webhook_url="",
|
||||
feishu_webhook_secret="",
|
||||
feishu_app_id="",
|
||||
feishu_app_secret="",
|
||||
session_secret="session-secret",
|
||||
)
|
||||
db = Database(config.database_path, config.employee_seed_path)
|
||||
server = create_server(config, db)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, db, f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
|
||||
def get(url: str) -> tuple[int, str]:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
return response.status, response.read().decode("utf-8")
|
||||
|
||||
|
||||
class WebTest(unittest.TestCase):
|
||||
def test_serves_submit_and_manager_pages(self) -> None:
|
||||
server, db, base_url = make_server()
|
||||
try:
|
||||
status, submit = get(f"{base_url}/submit")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("每日报告", submit)
|
||||
self.assertIn("1. \n2. \n3. \n4.", submit)
|
||||
self.assertIn("我的历史日报", submit)
|
||||
|
||||
status, manager = get(f"{base_url}/manager")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("日报浏览", manager)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
db.close()
|
||||
|
||||
def test_accepts_report_submission_and_returns_summary(self) -> None:
|
||||
server, db, base_url = make_server()
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
f"{base_url}/api/reports",
|
||||
data=json.dumps(
|
||||
{
|
||||
"feishu_user_id": "u_1",
|
||||
"report_date": "2026-05-07",
|
||||
"today_done": "完成 API",
|
||||
"tomorrow_plan": "完善界面",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8"),
|
||||
headers={"content-type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
status, body = get(f"{base_url}/api/reports?date=2026-05-07")
|
||||
summary = json.loads(body)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(summary["submittedCount"], 1)
|
||||
self.assertEqual(summary["reports"][0]["today_done"], "完成 API")
|
||||
|
||||
status, history_body = get(f"{base_url}/api/reports/history?feishu_user_id=u_1")
|
||||
history = json.loads(history_body)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(history["employee"]["name"], "Lin")
|
||||
self.assertEqual(history["reports"][0]["tomorrow_plan"], "完善界面")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
db.close()
|
||||
|
||||
def test_submit_page_uses_logged_in_feishu_identity(self) -> None:
|
||||
server, db, base_url = make_server()
|
||||
try:
|
||||
opener = urllib.request.build_opener()
|
||||
opener.addheaders = [
|
||||
(
|
||||
"Cookie",
|
||||
"daily_report_session="
|
||||
+ create_session_cookie({"feishu_user_id": "u_1", "name": "Lin"}, "session-secret"),
|
||||
)
|
||||
]
|
||||
with opener.open(f"{base_url}/submit", timeout=5) as response:
|
||||
body = response.read().decode("utf-8")
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIn("Lin", body)
|
||||
self.assertIn('type="hidden" name="feishu_user_id" value="u_1"', body)
|
||||
self.assertNotIn("员工 ID<input", body)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
33
tests/test_workday.py
Normal file
33
tests/test_workday.py
Normal file
@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from daily_report.workday import is_workday
|
||||
|
||||
|
||||
class WorkdayTest(unittest.TestCase):
|
||||
def test_uses_holidays_and_makeup_workdays(self) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="daily-report-workday-") as temp:
|
||||
calendar_path = Path(temp) / "calendar.json"
|
||||
calendar_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"holidays": ["2026-05-01"],
|
||||
"makeup_workdays": ["2026-05-09"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
self.assertFalse(is_workday(date(2026, 5, 1), calendar_path))
|
||||
self.assertTrue(is_workday(date(2026, 5, 9), calendar_path))
|
||||
self.assertFalse(is_workday(date(2026, 5, 10), calendar_path))
|
||||
self.assertTrue(is_workday(date(2026, 5, 11), calendar_path))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
17
uninstall-tasks.bat
Normal file
17
uninstall-tasks.bat
Normal file
@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
set "LOG=%~dp0uninstall-tasks.log"
|
||||
|
||||
echo Uninstalling Windows scheduled tasks...
|
||||
echo Uninstalling Windows scheduled tasks... > "%LOG%"
|
||||
echo Project: %CD% >> "%LOG%"
|
||||
echo. >> "%LOG%"
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\uninstall-windows-tasks.ps1" >> "%LOG%" 2>&1
|
||||
type "%LOG%"
|
||||
|
||||
echo.
|
||||
echo If there is no error above, tasks are removed.
|
||||
echo If there is an error, send Codex a screenshot.
|
||||
pause
|
||||
Loading…
x
Reference in New Issue
Block a user