2025-05-06 09:52:24 +08:00

177 lines
5.8 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Flask application providing task management and Google Calendar scheduling.
-------------------------------------------------------
Prerequisites:
• Python 3.9+
• pip install flask peewee google-api-python-client google-auth google-auth-httplib2 google-auth-oauthlib
Configuration:
Set environment variable API_KEY with the shared secret that clients must send in the
HTTP header `X-API-Key`.
Place your Google ServiceAccount JSON credentials next to this file and set
GOOGLE_CREDENTIALS_FILE (default 'credentials.json').
Set CALENDAR_ID to your target calendar (e.g. 'primary' or a specific ID).
Run with:
python app.py
"""
import os
import datetime as dt
from functools import wraps
from zoneinfo import ZoneInfo # Python 3.9+ standard timezone database
from flask import Flask, jsonify, request, abort
from peewee import (
Model, SqliteDatabase, AutoField, CharField, IntegerField, DateTimeField,
)
from google.oauth2 import service_account
from googleapiclient.discovery import build
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DATABASE_FILE = os.getenv("TASK_DB", "tasks.db")
API_KEY = os.getenv("API_KEY", "change-me")
CREDENTIALS_FILE = os.getenv("GOOGLE_CREDENTIALS_FILE", "credentials.json")
CALENDAR_ID = os.getenv("CALENDAR_ID", "primary")
SCOPES = ["https://www.googleapis.com/auth/calendar"]
TZ = ZoneInfo("Asia/Shanghai") # 使用北京时间
# ---------------------------------------------------------------------------
# Database setup
# ---------------------------------------------------------------------------
db = SqliteDatabase(DATABASE_FILE, check_same_thread=False)
class BaseModel(Model):
class Meta:
database = db
class Task(BaseModel):
id = AutoField()
name = CharField()
min_interval_days = IntegerField() # "最少 x 天执行一次"
last_schedule_id = CharField(null=True) # 上次日程 ID
last_execution_time = DateTimeField(null=True)
priority = IntegerField(default=0) # 优先级 (越大越高)
# Create table if it doesn't exist
with db:
db.create_tables([Task])
# ---------------------------------------------------------------------------
# Google Calendar client
# ---------------------------------------------------------------------------
creds = service_account.Credentials.from_service_account_file(
CREDENTIALS_FILE, scopes=SCOPES
)
calendar_service = build("calendar", "v3", credentials=creds, cache_discovery=False)
# ---------------------------------------------------------------------------
# Flask app + helpers
# ---------------------------------------------------------------------------
app = Flask(__name__)
def require_api_key(func):
"""Simple headerbased authentication decorator."""
@wraps(func)
def wrapper(*args, **kwargs):
if request.headers.get("X-API-Key") != API_KEY:
abort(401, description="Invalid API key")
return func(*args, **kwargs)
return wrapper
# ---------------------------------------------------------------------------
# Utilities
# ---------------------------------------------------------------------------
def _to_datetime(value):
"""Ensure value is a timezoneaware datetime."""
if not value:
return None
if isinstance(value, dt.datetime):
return value if value.tzinfo else value.replace(tzinfo=TZ)
# Peewee on SQLite may return str parse ISO 8601
parsed = dt.datetime.fromisoformat(value)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=TZ)
def compute_color(task: Task, now: dt.datetime) -> str:
"""Compute trafficlight color for a task based on last_execution_time."""
last_dt = _to_datetime(task.last_execution_time)
if last_dt is None:
return "green"
days_passed = (now - last_dt).total_seconds() / 86400
threshold = task.min_interval_days or 0
if days_passed <= (2 * threshold) / 3:
return "green"
elif days_passed <= threshold:
return "yellow"
return "red"
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/tasks", methods=["GET"])
@require_api_key
def list_tasks():
now = dt.datetime.now(TZ)
tasks = (
Task.select().order_by(Task.priority.desc())
)
response = [
{
"id": t.id,
"name": t.name,
"color": compute_color(t, now),
}
for t in tasks
]
return jsonify(response)
@app.route("/tasks/<int:task_id>/schedule", methods=["POST"])
@require_api_key
def schedule_task(task_id: int):
task = Task.get_or_none(Task.id == task_id)
if not task:
abort(404, description="Task not found")
# Event time is now (Asia/Shanghai)
start_dt = dt.datetime.now(TZ).replace(microsecond=0)
start_time = start_dt.isoformat()
event_body = {
"summary": task.name,
"description": task.name,
"start": {"dateTime": start_time, "timeZone": "Asia/Shanghai"},
"end": {"dateTime": start_time, "timeZone": "Asia/Shanghai"},
}
event = (
calendar_service.events()
.insert(calendarId=CALENDAR_ID, body=event_body)
.execute()
)
# Update task with new schedule info
task.last_schedule_id = event["id"]
task.last_execution_time = start_dt
task.save()
return jsonify({
"success": True,
"event_id": event["id"],
"task_id": task.id,
}), 201
# ---------------------------------------------------------------------------
# Application entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.getenv("PORT", 5000)), debug=True)