From 31390882ef0ade8f19a770d503ea47b9c581093f Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 20:19:58 +0200 Subject: [PATCH 01/17] Bootstrap Python rewrite skeleton --- .dockerignore | 10 + .env.example | 14 + .gitignore | 39 +- .vscode/launch.json | 39 +- Dockerfile | 20 + README.md | 176 +++++- alembic.ini | 37 ++ alembic/README | 2 + alembic/env.py | 46 ++ alembic/script.py.mako | 26 + alembic/versions/.gitkeep | 1 + app/__init__.py | 2 + app/api/__init__.py | 2 + app/api/routes/__init__.py | 2 + app/api/routes/pages.py | 21 + app/api/routes/status.py | 11 + app/config.py | 50 ++ app/db.py | 29 + app/dependencies.py | 15 + app/integrations/__init__.py | 2 + app/integrations/homeassistant.py | 12 + app/integrations/ticktick.py | 12 + app/main.py | 45 ++ app/models/__init__.py | 2 + app/models/base.py | 4 + app/schemas/__init__.py | 2 + app/schemas/health.py | 6 + app/services/__init__.py | 2 + app/services/system.py | 6 + app/static/styles.css | 95 +++ app/templates/base.html | 15 + app/templates/home.html | 33 ++ dev-requirements.in | 6 + dev-requirements.txt | 120 ++++ docker-compose.yml | 16 + docs/architecture-overview.md | 79 +++ docs/current-system-inventory.md | 557 ++++++++++++++++++ docs/migration-notes.md | 40 ++ docs/migration-risks.md | 238 ++++++++ docs/python-rewrite-plan.md | 314 ++++++++++ legacy/README.md | 18 + .../go-backend/.github}/workflows/nightly.yml | 0 .../.github}/workflows/short-tests.yml | 0 .../home_automation_backend_template.conf | 0 .../go-backend/helper}/install.sh | 0 {src => legacy/go-backend/src}/LICENSE | 0 {src => legacy/go-backend/src}/cmd/root.go | 0 {src => legacy/go-backend/src}/cmd/serve.go | 0 .../components/homeassistant/homeassistant.go | 0 .../homeassistant/homeassistant_test.go | 0 .../locationRecorder/locationRecorder.go | 0 .../components/pooRecorder/pooRecorder.go | 0 {src => legacy/go-backend/src}/go.mod | 0 {src => legacy/go-backend/src}/go.sum | 0 .../src}/helper/location_recorder/LICENSE | 0 .../helper/location_recorder/cmd/addgpx.go | 0 .../src}/helper/location_recorder/cmd/root.go | 0 .../src}/helper/location_recorder/main.go | 0 .../src}/helper/poo_recorder_helper/LICENSE | 0 .../helper/poo_recorder_helper/cmd/reverse.go | 0 .../helper/poo_recorder_helper/cmd/root.go | 0 .../src}/helper/poo_recorder_helper/main.go | 0 {src => legacy/go-backend/src}/main.go | 0 .../homeassistantutil/homeassistantutil.go | 0 .../go-backend/src}/util/notion/notion.go | 0 .../src}/util/ticktickutil/ticktickutil.go | 0 pyproject.toml | 28 + requirements.in | 8 + requirements.txt | 78 +++ scripts/export_openapi.py | 27 + tests/conftest.py | 15 + tests/test_app.py | 13 + 72 files changed, 2273 insertions(+), 62 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/.gitkeep create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/pages.py create mode 100644 app/api/routes/status.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/dependencies.py create mode 100644 app/integrations/__init__.py create mode 100644 app/integrations/homeassistant.py create mode 100644 app/integrations/ticktick.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/base.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/health.py create mode 100644 app/services/__init__.py create mode 100644 app/services/system.py create mode 100644 app/static/styles.css create mode 100644 app/templates/base.html create mode 100644 app/templates/home.html create mode 100644 dev-requirements.in create mode 100644 dev-requirements.txt create mode 100644 docker-compose.yml create mode 100644 docs/architecture-overview.md create mode 100644 docs/current-system-inventory.md create mode 100644 docs/migration-notes.md create mode 100644 docs/migration-risks.md create mode 100644 docs/python-rewrite-plan.md create mode 100644 legacy/README.md rename {.github => legacy/go-backend/.github}/workflows/nightly.yml (100%) rename {.github => legacy/go-backend/.github}/workflows/short-tests.yml (100%) rename {helper => legacy/go-backend/helper}/home_automation_backend_template.conf (100%) rename {helper => legacy/go-backend/helper}/install.sh (100%) rename {src => legacy/go-backend/src}/LICENSE (100%) rename {src => legacy/go-backend/src}/cmd/root.go (100%) rename {src => legacy/go-backend/src}/cmd/serve.go (100%) rename {src => legacy/go-backend/src}/components/homeassistant/homeassistant.go (100%) rename {src => legacy/go-backend/src}/components/homeassistant/homeassistant_test.go (100%) rename {src => legacy/go-backend/src}/components/locationRecorder/locationRecorder.go (100%) rename {src => legacy/go-backend/src}/components/pooRecorder/pooRecorder.go (100%) rename {src => legacy/go-backend/src}/go.mod (100%) rename {src => legacy/go-backend/src}/go.sum (100%) rename {src => legacy/go-backend/src}/helper/location_recorder/LICENSE (100%) rename {src => legacy/go-backend/src}/helper/location_recorder/cmd/addgpx.go (100%) rename {src => legacy/go-backend/src}/helper/location_recorder/cmd/root.go (100%) rename {src => legacy/go-backend/src}/helper/location_recorder/main.go (100%) rename {src => legacy/go-backend/src}/helper/poo_recorder_helper/LICENSE (100%) rename {src => legacy/go-backend/src}/helper/poo_recorder_helper/cmd/reverse.go (100%) rename {src => legacy/go-backend/src}/helper/poo_recorder_helper/cmd/root.go (100%) rename {src => legacy/go-backend/src}/helper/poo_recorder_helper/main.go (100%) rename {src => legacy/go-backend/src}/main.go (100%) rename {src => legacy/go-backend/src}/util/homeassistantutil/homeassistantutil.go (100%) rename {src => legacy/go-backend/src}/util/notion/notion.go (100%) rename {src => legacy/go-backend/src}/util/ticktickutil/ticktickutil.go (100%) create mode 100644 pyproject.toml create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100644 scripts/export_openapi.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b11b95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.pytest_cache +.venv +__pycache__ +*.pyc +data +openapi +src + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9c7351 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +APP_NAME=Home Automation Backend (Python) +APP_ENV=development +APP_DEBUG=true +APP_HOST=0.0.0.0 +APP_PORT=8000 +DATABASE_URL=sqlite:///./data/app.db +TICKTICK_CLIENT_ID= +TICKTICK_CLIENT_SECRET= +TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback +TICKTICK_TOKEN= +HOME_ASSISTANT_BASE_URL=http://localhost:8123 +HOME_ASSISTANT_AUTH_TOKEN= +HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= + diff --git a/.gitignore b/.gitignore index 4bd78c5..1d5c43b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,8 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file .env - -temp_data/ - -# py file for branch switching -.venv -__pycache__/ .pytest_cache/ -config.yaml -bin/ -*.db +.venv/ +__pycache__/ +*.pyc +data/ +openapi/ -cover.html \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index c6a1460..5d9517f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,35 +1,20 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - "name": "Launch Package", - "type": "go", + "name": "Launch Python App", + "type": "debugpy", "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}" - }, - { - "name": "Launch Poo Reverse", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}/src/helper/poo_recorder_helper/main.go", + "module": "uvicorn", "args": [ - "reverse" - ] - }, - { - "name": "Launch Home Automation", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}/src/main.go", - "args": [ - "serve" - ] + "app.main:app", + "--reload", + "--host", + "0.0.0.0", + "--port", + "8000" + ], + "jinja": true } ] -} \ No newline at end of file +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ad5727 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini ./ +COPY scripts ./scripts +COPY README.md ./ +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 47d85dc..2e20eda 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,177 @@ # Home Automation Backend -![CI-Short](https://code.wanderingbadger.dev/tliu93/home-automation-backend.git/actions/workflows/short-tests.yml/badge.svg) \ No newline at end of file +这是当前 `home-automation` 项目的 Python 重构基础骨架。当前仓库仍保留 Go 版本作为事实基线,而这个 Python 部分的目标是为后续逐模块迁移提供稳定工程基础。 + +为便于清理仓库,重构开始前就存在的 Go 实现和相关资产已经统一移动到 `legacy/go-backend/`。这样在 Python 重构完成后,可以按目录整体删除旧实现。 + +当前阶段只包含: + +- FastAPI 基础应用骨架 +- 环境变量配置体系 +- SQLite + SQLAlchemy + Alembic 基础设施 +- 极简 server-side templates +- pytest 测试基础 +- OpenAPI 导出脚本 +- Docker / Compose 基础骨架 + +当前阶段明确不包含: + +- TickTick 业务逻辑迁移 +- Home Assistant 业务逻辑迁移 +- poo records 业务迁移 +- location / life trajectory 业务迁移 +- Notion 模块 + +Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 + +旧 Go 代码位置: + +- `legacy/go-backend/src/` +- `legacy/go-backend/helper/` +- `legacy/go-backend/.github/workflows/` + +## 当前目录 + +Python 骨架的主要目录如下: + +- `app/`: FastAPI 应用代码 +- `alembic/`: Alembic migration 环境 +- `tests/`: pytest 测试 +- `docs/`: 架构说明与迁移文档 +- `scripts/`: 辅助脚本,例如 OpenAPI 导出 + +## 依赖管理 + +项目现在采用 `pip-tools` 管理依赖: + +- 生产依赖源文件:`requirements.in` +- 开发依赖源文件:`dev-requirements.in` +- 编译产物: + - `requirements.txt` + - `dev-requirements.txt` + +更新依赖时建议使用: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install pip-tools +pip-compile requirements.in +pip-compile dev-requirements.in +``` + +如果要升级某个依赖,可以用: + +```bash +pip-compile --upgrade-package fastapi requirements.in +pip-compile dev-requirements.in +``` + +## 本地启动 + +建议使用 Python 3.11 或以上版本。 + +1. 创建虚拟环境并安装依赖 + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r dev-requirements.txt +``` + +2. 准备环境变量 + +```bash +cp .env.example .env +``` + +3. 启动服务 + +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +启动后可访问: + +- 应用首页:`http://localhost:8000/` +- 健康检查:`http://localhost:8000/status` +- Swagger UI:`http://localhost:8000/docs` +- ReDoc:`http://localhost:8000/redoc` + +## 数据库与 Alembic + +当前默认数据库使用 SQLite。 + +- 默认数据库地址:`sqlite:///./data/app.db` +- 数据目录:`./data/` + +初始化 migration 环境后,可继续添加模型并生成迁移: + +```bash +alembic revision --autogenerate -m "init tables" +alembic upgrade head +``` + +这一轮尚未引入业务表,因此 Alembic 目前主要是基础设施就绪状态。 + +## 运行测试 + +```bash +pytest +``` + +当前测试包含: + +- app 基本启动测试 +- `/status` endpoint 测试 + +## OpenAPI 导出 + +FastAPI 默认会暴露 OpenAPI。若需要导出静态 schema 文件,可运行: + +```bash +python scripts/export_openapi.py +``` + +输出文件会写到: + +- `openapi/openapi.json` +- `openapi/openapi.yaml` + +## 容器启动 + +1. 准备环境变量文件 + +```bash +cp .env.example .env +``` + +2. 启动容器 + +```bash +docker compose up --build +``` + +默认端口: + +- `8000:8000` + +SQLite 持久化目录: + +- 本地 `./data` +- 容器内 `/app/data` + +## 后续迁移建议 + +后续可以在当前骨架上逐步迁移这些模块: + +- TickTick integration +- Home Assistant integration +- poo records +- location / life trajectory + +建议继续参考: + +- [当前系统盘点](docs/current-system-inventory.md) +- [Python 重构方案](docs/python-rewrite-plan.md) +- [迁移风险清单](docs/migration-risks.md) diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7c7143b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./data/app.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s + diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..0eb160c --- /dev/null +++ b/alembic/README @@ -0,0 +1,2 @@ +This directory contains the Alembic migration environment for the Python rewrite skeleton. + diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7fa3bc7 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,46 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import get_settings +from app.models.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..2e8960a --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} + diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..09cd07d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Application package for the Python rewrite skeleton.""" + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..b05dfd6 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API package.""" + diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..216c2a5 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,2 @@ +"""Route modules.""" + diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py new file mode 100644 index 0000000..2bca83b --- /dev/null +++ b/app/api/routes/pages.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from app.config import Settings +from app.dependencies import get_app_settings + +templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) +router = APIRouter(tags=["pages"]) + + +@router.get("/", response_class=HTMLResponse) +def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse: + context = { + "app_name": settings.app_name, + "app_env": settings.app_env, + "notion_status": "Legacy scope, removed from the Python rewrite target.", + } + return templates.TemplateResponse(request, "home.html", context) diff --git a/app/api/routes/status.py b/app/api/routes/status.py new file mode 100644 index 0000000..0e21cc2 --- /dev/null +++ b/app/api/routes/status.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.schemas.health import StatusResponse + +router = APIRouter(tags=["system"]) + + +@router.get("/status", response_model=StatusResponse) +def get_status() -> StatusResponse: + return StatusResponse(status="ok") + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d42e2d4 --- /dev/null +++ b/app/config.py @@ -0,0 +1,50 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Home Automation Backend (Python)" + app_env: str = "development" + app_debug: bool = False + app_host: str = "0.0.0.0" + app_port: int = 8000 + + database_url: str = "sqlite:///./data/app.db" + + ticktick_client_id: str = "" + ticktick_client_secret: str = "" + ticktick_redirect_uri: str = "" + ticktick_token: str = "" + + home_assistant_base_url: str = "" + home_assistant_auth_token: str = "" + home_assistant_action_task_project_id: str = "" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + @computed_field + @property + def is_development(self) -> bool: + return self.app_env.lower() == "development" + + @computed_field + @property + def sqlite_path(self) -> Path | None: + prefix = "sqlite:///" + if not self.database_url.startswith(prefix): + return None + raw_path = self.database_url[len(prefix) :] + return Path(raw_path) + + +@lru_cache +def get_settings() -> Settings: + return Settings() + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..efbb463 --- /dev/null +++ b/app/db.py @@ -0,0 +1,29 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() + +connect_args: dict[str, object] = {} +if settings.database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine(settings.database_url, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) + + +def get_db_session() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() + diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..fb2f700 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,15 @@ +from collections.abc import Generator + +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.db import get_db_session + + +def get_app_settings() -> Settings: + return get_settings() + + +def get_db() -> Generator[Session, None, None]: + yield from get_db_session() + diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py new file mode 100644 index 0000000..999e12a --- /dev/null +++ b/app/integrations/__init__.py @@ -0,0 +1,2 @@ +"""External integration placeholders for future migration.""" + diff --git a/app/integrations/homeassistant.py b/app/integrations/homeassistant.py new file mode 100644 index 0000000..944839c --- /dev/null +++ b/app/integrations/homeassistant.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from app.config import Settings + + +@dataclass(slots=True) +class HomeAssistantClient: + settings: Settings + + def is_configured(self) -> bool: + return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) + diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py new file mode 100644 index 0000000..8dc15c3 --- /dev/null +++ b/app/integrations/ticktick.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from app.config import Settings + + +@dataclass(slots=True) +class TickTickClient: + settings: Settings + + def is_configured(self) -> bool: + return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret) + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e92b0cd --- /dev/null +++ b/app/main.py @@ -0,0 +1,45 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.api.routes import pages, status +from app.config import get_settings + + +def ensure_runtime_dirs() -> None: + settings = get_settings() + if settings.sqlite_path is not None: + settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + ensure_runtime_dirs() + yield + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title=settings.app_name, + debug=settings.app_debug, + version="0.1.0", + lifespan=lifespan, + description=( + "Python rewrite skeleton for the home automation backend. " + "This stage provides only the foundation for future module migration." + ), + ) + + static_dir = Path(__file__).parent / "static" + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + app.include_router(status.router) + app.include_router(pages.router) + return app + + +app = create_app() + diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..becfbcb --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,2 @@ +"""SQLAlchemy models package.""" + diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..b852be4 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,4 @@ +from app.db import Base + +__all__ = ["Base"] + diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..660f49e --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,2 @@ +"""Pydantic schemas package.""" + diff --git a/app/schemas/health.py b/app/schemas/health.py new file mode 100644 index 0000000..cc351f8 --- /dev/null +++ b/app/schemas/health.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class StatusResponse(BaseModel): + status: str + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..b234c8f --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Service layer package.""" + diff --git a/app/services/system.py b/app/services/system.py new file mode 100644 index 0000000..d2f4f75 --- /dev/null +++ b/app/services/system.py @@ -0,0 +1,6 @@ +from app.config import Settings + + +def build_status_payload(settings: Settings) -> dict[str, str]: + return {"status": "ok", "environment": settings.app_env} + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..eddcec4 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,95 @@ +:root { + --bg: #f4f1ea; + --panel: rgba(255, 255, 255, 0.88); + --text: #1f2933; + --muted: #5b6875; + --accent: #2d6a4f; + --border: rgba(31, 41, 51, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(45, 106, 79, 0.18), transparent 28%), + linear-gradient(160deg, #f7f4ee 0%, #ece6d8 100%); +} + +.shell { + width: min(880px, calc(100% - 32px)); + margin: 48px auto; +} + +.panel { + padding: 32px; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--panel); + backdrop-filter: blur(12px); + box-shadow: 0 20px 60px rgba(31, 41, 51, 0.12); +} + +.eyebrow { + margin: 0 0 8px; + font-size: 0.85rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +h1 { + margin: 0 0 16px; + font-size: clamp(2rem, 4vw, 3.2rem); +} + +.lead { + margin: 0 0 24px; + line-height: 1.7; + color: var(--muted); +} + +.meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin: 0; +} + +.meta div { + padding: 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(31, 41, 51, 0.06); +} + +.meta dt { + margin-bottom: 8px; + font-size: 0.9rem; + color: var(--muted); +} + +.meta dd { + margin: 0; + font-size: 1.05rem; +} + +a { + color: var(--accent); +} + +@media (max-width: 640px) { + .shell { + margin: 24px auto; + } + + .panel { + padding: 24px; + } +} + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..5c55712 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,15 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..559a65a --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}{{ app_name }}{% endblock %} + +{% block content %} +
+

Python Rewrite Skeleton

+

{{ app_name }}

+

+ 这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、 + 测试、模板和容器化基础,不包含业务逻辑迁移。 +

+
+
+
运行环境
+
{{ app_env }}
+
+
+
健康检查
+
/status
+
+
+
OpenAPI
+
/docs
+
+
+
Notion
+
{{ notion_status }}
+
+
+
+{% endblock %} + diff --git a/dev-requirements.in b/dev-requirements.in new file mode 100644 index 0000000..64e8cc4 --- /dev/null +++ b/dev-requirements.in @@ -0,0 +1,6 @@ +-r requirements.in + +httpx>=0.28,<1.0 +pip-tools>=7.4,<8.0 +pytest>=8.3,<9.0 + diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..ed4276e --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,120 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile dev-requirements.in +# +alembic==1.18.4 + # via -r requirements.in +annotated-types==0.7.0 + # via pydantic +anyio==4.13.0 + # via + # httpx + # starlette + # watchfiles +build==1.4.3 + # via pip-tools +certifi==2026.2.25 + # via + # httpcore + # httpx +click==8.3.2 + # via + # pip-tools + # uvicorn +fastapi==0.115.14 + # via -r requirements.in +greenlet==3.4.0 + # via sqlalchemy +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httptools==0.7.1 + # via uvicorn +httpx==0.28.1 + # via -r dev-requirements.in +idna==3.11 + # via + # anyio + # httpx +iniconfig==2.3.0 + # via pytest +jinja2==3.1.6 + # via -r requirements.in +mako==1.3.11 + # via alembic +markupsafe==3.0.3 + # via + # jinja2 + # mako +packaging==26.1 + # via + # build + # pytest + # wheel +pip-tools==7.5.3 + # via -r dev-requirements.in +pluggy==1.6.0 + # via pytest +pydantic==2.13.2 + # via + # fastapi + # pydantic-settings +pydantic-core==2.46.2 + # via pydantic +pydantic-settings==2.13.1 + # via -r requirements.in +pygments==2.20.0 + # via pytest +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pytest==8.4.2 + # via -r dev-requirements.in +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.26 + # via -r requirements.in +pyyaml==6.0.3 + # via + # -r requirements.in + # uvicorn +sqlalchemy==2.0.49 + # via + # -r requirements.in + # alembic +starlette==0.46.2 + # via fastapi +typing-extensions==4.15.0 + # via + # alembic + # fastapi + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-settings +uvicorn[standard]==0.44.0 + # via -r requirements.in +uvloop==0.22.1 + # via uvicorn +watchfiles==1.1.1 + # via uvicorn +websockets==16.0 + # via uvicorn +wheel==0.46.3 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c8b447a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.9" + +services: + app: + build: . + ports: + - "8000:8000" + env_file: + - .env + environment: + DATABASE_URL: sqlite:////app/data/app.db + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + volumes: + - ./data:/app/data + diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md new file mode 100644 index 0000000..a2c878e --- /dev/null +++ b/docs/architecture-overview.md @@ -0,0 +1,79 @@ +# Python 骨架架构概览 + +本文档说明当前 Python skeleton 的职责边界与目录组织。它描述的是“后续迁移承载体”,不是完整业务实现。 + +## 当前目标 + +这一轮的目标是提供一个稳定、轻量、可持续扩展的基础工程,使后续可以逐步迁移: + +- TickTick integration +- Home Assistant integration +- poo records +- location / life trajectory + +## 目录设计 + +### `app/` + +应用核心代码目录。 + +- `main.py` + - FastAPI app factory + - lifespan + - 基础路由注册 +- `config.py` + - 环境变量驱动的 settings +- `db.py` + - SQLAlchemy engine / session / Base +- `dependencies.py` + - 通用依赖注入 +- `api/` + - HTTP routes +- `models/` + - SQLAlchemy models +- `schemas/` + - Pydantic schemas +- `services/` + - 业务服务层 +- `integrations/` + - 外部系统适配层占位 +- `templates/` + - Jinja2 模板 +- `static/` + - 极简静态资源 + +### `alembic/` + +数据库 migration 基础设施。当前尚未迁入业务表,但迁移链路已就绪。 + +### `tests/` + +pytest 测试目录。后续可以在这里自然扩展: + +- unit tests +- mock tests +- integration tests + +### `scripts/` + +辅助脚本目录。当前包含 OpenAPI 导出脚本。 + +## 当前约束 + +- 当前只搭骨架,不迁业务逻辑 +- 当前数据库继续使用 SQLite +- 当前不引入前后端分离 +- 当前不设计 Notion 模块 + +## 关于 Notion + +Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确属于 removed scope。 + +因此当前 Python skeleton: + +- 不提供 Notion integration 模块 +- 不提供 Notion schema +- 不预留 Notion 相关业务流 + +如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 + diff --git a/docs/current-system-inventory.md b/docs/current-system-inventory.md new file mode 100644 index 0000000..c85cfb1 --- /dev/null +++ b/docs/current-system-inventory.md @@ -0,0 +1,557 @@ +# 当前系统盘点 + +本文档用于盘点当前 branch 上的 Go 实现,并将其作为后续 Python 重构的唯一事实基线。 + +## 范围与基线 + +- 当前事实基线:`legacy/go-backend/src/` 下的 Go 代码 +- 不纳入当前基线:更早的 Python 版本 +- 主入口:[`legacy/go-backend/src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62) + +## 系统概览 + +当前应用是一个单进程 Go HTTP 服务,具备以下特征: + +- 暴露少量 REST API +- 使用本地 SQLite 做持久化 +- 调用 Home Assistant API 和 webhook +- 通过 OAuth 和 REST API 集成 TickTick +- 当前仍依赖 Notion 做 poo 记录同步 +- 内置一个每日执行的定时同步任务 + +进程启动后会先读取 YAML 配置文件,再初始化 Notion 与 TickTick 工具层、初始化各业务组件自己的 SQLite 数据库、注册路由、启动调度器,最后在配置的端口上提供 HTTP 服务。可参考 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) 和 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:104)。 + +## API 盘点 + +### `GET /status` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:106) +- 用途:基础存活检查 +- 请求参数:无 +- 请求体:无 +- 响应:纯文本 `OK` +- 鉴权:当前代码中无鉴权 +- 调用方类型:通用健康检查,可能用于本地监控或 supervisor 级别的探活 + +### `GET /poo/latest` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:110) +- 处理函数:[`pooRecorder.HandleNotifyLatestPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:87) +- 用途:将最新一条 poo 状态重新发布到 Home Assistant 的 sensor state +- 请求参数:无 +- 请求体:无 +- 响应: + - 成功:空响应体,默认 HTTP 200 + - 失败:通过 `http.Error(...)` 返回文本错误信息 +- 鉴权:当前代码中无鉴权 +- 外部调用方: + - 会被 `POST /homeassistant/publish` 间接触发,当 `target=poo_recorder` 且 `action=get_latest` 时,代码会通过内部 HTTP 请求访问 `http://localhost:{port}/poo/latest`,见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:110) +- 副作用: + - 从 `poo_records` 读取最新一条记录 + - 调用 Home Assistant `/api/states/{entity_id}` 更新 sensor 状态,见 [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:65) + +### `POST /poo/record` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:111) +- 处理函数:[`pooRecorder.HandleRecordPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57) +- 用途:记录一条 poo 事件,同时镜像到 Notion、刷新 Home Assistant sensor,并可选触发一个 Home Assistant webhook +- 请求体 JSON: + - `status: string` + - `latitude: string` + - `longitude: string` +- 请求校验: + - JSON decoder 开启了 `DisallowUnknownFields` + - 如果配置里缺少 `pooRecorder.tableId`,请求会直接失败,虽然从纯本地 DB 角度看本来仍有可能写入成功 +- 响应: + - 成功:空响应体,默认 HTTP 200 + - 请求错误:返回 decoder 错误文本,HTTP 400 + - 服务端错误:返回错误文本,HTTP 500 +- 鉴权:当前代码中无鉴权 +- 外部调用方:大概率是 Home Assistant、移动端 shortcut、或手工调用;代码中没有明确写死调用方 +- 副作用: + - 向 SQLite `poo_records` 插入一条记录 + - 异步向 Notion 追加一行 + - 同步发布最新 Home Assistant sensor 状态 + - 如果存在 `pooRecorder.webhookId`,异步触发一个 Home Assistant webhook + +### `POST /homeassistant/publish` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:112) +- 处理函数:[`HomeAssistant.HandleHaMessage`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36) +- 用途:接收自动化消息,根据 `target` 和 `action` 做分发 +- 请求体 JSON: + - `target: string` + - `action: string` + - `content: string` +- 请求校验: + - JSON decoder 开启了 `DisallowUnknownFields` +- 响应: + - 成功路径:空响应体,默认 HTTP 200 + - 失败路径:通常为空响应体并返回 HTTP 500;TickTick auth 回调是单独的接口,不在这里 +- 鉴权:当前代码中无鉴权 +- 外部调用方:设计意图上是给 Home Assistant automation 消息调用 + +当前代码支持的消息契约如下: + +- `target=poo_recorder`, `action=get_latest` + - 转发到本地 `GET /poo/latest` + - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:72) + +- `target=location_recorder`, `action=record` + - `content` 预期是一个 JSON 风格字符串,实际很可能使用单引号 + - 当前代码会用 `strings.ReplaceAll(message.Content, "'", "\"")` 做归一化 + - 然后转发到本地 `POST /location/record` + - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82) + +- `target=ticktick`, `action=create_action_task` + - `content` 预期可解析为: + - `action: string` + - `due_hour: int` + - 当前代码会忽略调用方传来的 `title` 字段,而是把 `action` 映射为 TickTick task title + - 到期时间的计算方式是:取 `now + due_hour` 后所在日期的“次日零点”,再转成 TickTick 使用的时间格式 + - 最终在配置指定的 TickTick project 中创建任务 + - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:124) + +不支持的 `target` 或 `action` 会返回 HTTP 500,并打 warning 日志。相关测试在 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:68)。 + +### `POST /location/record` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:114) +- 处理函数:[`locationRecorder.HandleRecordLocation`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:43) +- 用途:记录人的位置点,用于人生轨迹 / movement history +- 请求体 JSON: + - `person: string` + - `latitude: string` + - `longitude: string` + - `altitude: string`,从请求结构上看是可选,但代码里即使为空也会被解析成 `0` +- 请求校验: + - JSON decoder 开启了 `DisallowUnknownFields` + - 数值解析错误会被忽略;如果 `latitude` / `longitude` / `altitude` 不是合法数字,当前实现会静默落成 `0` +- 响应: + - 成功:空响应体,默认 HTTP 200 + - 请求错误:返回 decoder 错误文本,HTTP 400 +- 鉴权:当前代码中无鉴权 +- 外部调用方: + - 可被任意客户端直接调用 + - 也会被 `POST /homeassistant/publish` 间接触发 +- 副作用: + - 向 SQLite `location` 表插入一条记录 + +### `GET /ticktick/auth/code` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:116) +- 处理函数:[`TicktickUtilImpl.HandleAuthCode`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103) +- 用途:TickTick OAuth redirect callback +- Query 参数: + - `state` + - `code` +- 响应: + - 成功:纯文本 `Authorization successful` + - 失败:纯文本错误信息,HTTP 400 或 500 +- 鉴权: + - 通过 OAuth `state` 与进程内保存的 `authState` 做校验 + - 没有额外的 session 或用户级鉴权 +- 外部调用方:TickTick OAuth redirect +- 副作用: + - 用 authorization code 换取 access token + - 通过 `viper.WriteConfig()` 把 `ticktick.token` 写回 YAML 配置文件 + +## 外部集成盘点 + +### TickTick + +- 在 Python 重构中的状态:应保留 +- 主要文件: + - [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:1) + - [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:100) +- 当前职责: + - 初始化 TickTick 鉴权状态 + - 当 token 缺失时启动 OAuth 授权 + - 接收 OAuth callback 并持久化 token + - 读取 project 下的 tasks + - 若不存在同名任务,则创建新任务 +- 连接方式: + - OAuth authorization code flow + - 调用 `https://ticktick.com/oauth/token` + - 调用 `https://api.ticktick.com/open/v1/...` +- 依赖的配置项: + - `ticktick.clientId` + - `ticktick.clientSecret` + - `ticktick.redirectUri` + - `ticktick.token` +- 关键实现依赖: + - 原生 `net/http` + - `viper`,同时承担配置读取和配置回写 +- 迁移高风险点: + - OAuth callback 的 `state` 只保存在进程内;如果服务在授权开始和回调完成之间重启,流程会断 + - token 直接写回 YAML 配置文件,虽然简单,但运维上比较脆弱 + - 去重逻辑只按 task title 精确匹配 + - due date 的计算语义是隐含在代码里的,重构前应先冻结 + - `Init()` 会在启动时积极检查配置,且在 token 缺失时打印手动授权 URL;Python 版需要明确是否仍要在启动阶段卡住这一流程 + +### Home Assistant + +- 在 Python 重构中的状态:应保留 +- 主要文件: + - [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:1) + - [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:1) +- 当前职责: + - 接收来自 Home Assistant automations 的命令 envelope + - 将命令转发给本地模块 + - 把 sensor state 发布回 Home Assistant + - 在 poo 记录后触发 Home Assistant webhook +- 连接方式: + - 入站 webhook 风格 JSON 接口:`POST /homeassistant/publish` + - 出站 REST 调用:Home Assistant `/api/states/{entity_id}` + - 出站 webhook 调用:`/api/webhook/{webhook_id}` + - 出站调用使用 bearer token +- 依赖的配置项: + - `homeassistant.ip` + - `homeassistant.port` + - `homeassistant.authToken` + - `homeassistant.actionTaskProjectId` + - `pooRecorder.webhookId` + - `pooRecorder.sensorEntityName` + - `pooRecorder.sensorFriendlyName` +- 关键实现依赖: + - 原生 `net/http` + - 通过 `localhost:{port}` 发起自调用,而不是直接走函数调用 +- 迁移高风险点: + - 入站 `/homeassistant/publish` 当前没有鉴权 + - 当前命令 envelope 里的 `content` 是字符串,且常带单引号,现有客户端可能依赖这种非标准格式 + - 模块间当前是通过自调用 HTTP 和 1 秒 timeout 编排的 + - sensor 发布和 webhook 触发都属于强副作用行为,需要在兼容性测试里单独覆盖 + +### Notion + +- 在 Python 重构中的状态:当前存在,但按已知目标不计划默认保留 +- 主要文件: + - [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:1) + - [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191) + - helper CLI:[`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) +- 当前职责: + - 使用 config token 初始化 Notion client + - 读取 / 写入 poo 记录对应的表格行 + - 每日做 SQLite 和 Notion 的双向同步 + - 提供一个反转 Notion 表顺序的辅助 CLI +- 连接方式: + - 通过 `github.com/jomei/notionapi` 调用 Notion API + - token 鉴权 +- 依赖的配置项: + - `notion.token` + - `pooRecorder.tableId` +- 关键实现依赖: + - `github.com/jomei/notionapi` +- 迁移高风险点: + - 当前服务启动时如果缺少 `notion.token` 会直接退出,即便 Notion 并不是系统所有功能都需要的基础能力 + - `POST /poo/record` 当前要求 `pooRecorder.tableId` 存在,并会异步镜像到 Notion + - 每日定时同步会同时改写 Notion 和 SQLite,若 Python 版移除这一行为,数据一致性预期会发生变化 + +## 数据库与 Schema 盘点 + +### 数据库类型 + +- 当前使用 SQLite 做组件级持久化 +- poo recorder 中显式导入了 SQLite driver,见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:20) +- location recorder 也通过 driver 名 `sqlite` 打开 SQLite,见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:80) + +### Poo recorder 数据库 + +- 配置项:`pooRecorder.dbPath` +- 默认路径:`pooRecorder.db` +- migration 机制: + - 手写 `PRAGMA user_version` + - 当前有效版本可以认为是 `1` + - 目前只实现了 `0 -> 1` +- 表: + - `poo_records` + - schema 定义见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:162) +- 字段: + - `timestamp TEXT PRIMARY KEY` + - `status TEXT NOT NULL` + - `latitude REAL NOT NULL` + - `longitude REAL NOT NULL` +- 核心用途: + - 用于查询最新 poo 状态并发布到 Home Assistant sensor + - 作为本地 poo 历史的持久化来源 + - 作为 Notion 双向同步的本地数据源和数据汇 +- 明显核心字段: + - `timestamp` + - `status` + - `latitude` + - `longitude` +- 可能属于历史包袱 / 后续需要再判断的点: + - 当前实现与 Notion 表行结构高度耦合 + - 时间戳是字符串,格式为 `2006-01-02T15:04Z07:00`,不是带秒的完整 RFC3339 + - API 请求模型接受的经纬度是字符串,因此 Python 版的类型规范化要小心兼容 + +### Location recorder 数据库 + +- 配置项:`locationRecorder.dbPath` +- 默认路径:`location_recorder.db` +- migration 机制: + - 手写 `PRAGMA user_version` + - 当前版本 `2` + - 已实现 migration: + - `0 -> 1`:建表 + - `1 -> 2`:把旧 datetime 字符串改写成 RFC3339 UTC +- 表: + - `location` + - schema 定义见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:115) +- 字段: + - `person TEXT NOT NULL` + - `datetime TEXT NOT NULL` + - `latitude REAL NOT NULL` + - `longitude REAL NOT NULL` + - `altitude REAL` + - 主键 `(person, datetime)` +- 核心用途: + - 持久化人生轨迹 / 位置点记录 +- 明显核心字段: + - `person` + - `datetime` + - `latitude` + - `longitude` +- 可能属于历史包袱 / 后续需要再判断的点: + - `altitude` 在语义上是可选,但当前入站解析会把缺失和非法值一起压成 `0` + - 当前没有查询 API,这张表目前主要承担“只写不读”的存储角色 + +### 跨模块数据库观察 + +- 当前没有统一的共享 schema;每个组件各自打开自己的 SQLite 文件 +- 没有使用 ORM +- 没有统一 migration 框架 +- 除主键外,没有看到额外索引 +- `poo` 的常规写入和异步 Notion 镜像之间没有事务保证,一致性更接近 best-effort + +## 业务模块拆分 + +### 1. HTTP 外壳 / 应用启动层 + +- 职责: + - 读取配置 + - 设置日志级别 + - 管理 scheduler 生命周期 + - 注册路由 + - 处理优雅退出 +- 主要文件:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62) +- 依赖: + - 所有业务模块 +- 迁移判断: + - 这部分后续应成为 FastAPI 的 app 装配层 + - 可以在早期先迁为“薄壳” + +### 2. Poo recorder + +- 职责: + - 接收 poo 记录 + - 持久化本地 poo 历史 + - 向 Home Assistant 发布最新状态 sensor + - 触发可选 Home Assistant webhook + - 与 Notion 做同步 +- 主要文件:[`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:50) +- 依赖: + - SQLite + - Home Assistant util + - Notion util + - scheduler +- 迁移判断: + - 这是功能上重要、但耦合也最重的模块 + - 适合在设计上先拆成: + - poo API / service + - Home Assistant 发布适配层 + - legacy Notion sync adapter + +### 3. Location recorder + +- 职责: + - 接收位置更新 + - 持久化人生轨迹点 +- 主要文件:[`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:39) +- 依赖: + - SQLite +- 迁移判断: + - 相对独立 + - 很适合作为优先迁移对象 + - 但需要先明确数值校验规则,因为当前实现会把非法数字静默压成 `0` + +### 4. Home Assistant 命令路由层 + +- 职责: + - 接收命令 envelope + - 根据 target/action 调度 poo、location、ticktick 行为 +- 主要文件:[`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36) +- 依赖: + - 本地服务端口 + - TickTick util +- 迁移判断: + - 它是外部 automations 的关键契约 + - 应在迁移早中期就被冻结和复刻 + +### 5. TickTick adapter + +- 职责: + - OAuth callback + - project / task REST 操作 + - 任务去重 +- 主要文件:[`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:81) +- 依赖: + - TickTick API + - 可写配置文件 +- 迁移判断: + - 作为内部 adapter 相对独立 + - 复杂度中等,主要难点是 OAuth 和 token 持久化 + +### 6. Home Assistant 出站 client + +- 职责: + - 发布 sensor state + - 触发 webhook +- 主要文件:[`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:30) +- 依赖: + - Home Assistant API/token +- 迁移判断: + - 小而独立 + - 很适合作为较早迁移的适配层 + +### 7. Notion adapter 与辅助 CLI + +- 职责: + - 读写 Notion table rows + - 维护 poo 相关表格的辅助操作 +- 主要文件: + - [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:14) + - [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) +- 迁移判断: + - 当前存在,但按已知方向应视为 planned non-migration + - 更适合被标记为 legacy 模块,而不是直接带进 Python 主体 + +### 8. 辅助 helper CLI + +- `src/helper/poo_recorder_helper` + - 用于 Notion 表反转的运维辅助工具 +- `src/helper/location_recorder` + - 当前基本还是脚手架,没有实质业务逻辑 +- 迁移判断: + - 二者都不是后端重构的核心目标 + - `poo_recorder_helper` 应随着 Notion 一并视作 legacy + +## 运行方式与部署形态 + +### 配置方式 + +- 配置文件名:`config.yaml` +- 搜索路径: + - 当前工作目录 + - `$HOME/.config/home-automation` +- 配置加载代码:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) +- 开启了 `viper.WatchConfig()` + +代码中实际出现的配置项包括: + +- `port` +- `logLevel` +- `notion.token` +- `ticktick.clientId` +- `ticktick.clientSecret` +- `ticktick.redirectUri` +- `ticktick.token` +- `homeassistant.ip` +- `homeassistant.port` +- `homeassistant.authToken` +- `homeassistant.actionTaskProjectId` +- `pooRecorder.tableId` +- `pooRecorder.webhookId` +- `pooRecorder.sensorEntityName` +- `pooRecorder.sensorFriendlyName` +- `pooRecorder.dbPath` +- `locationRecorder.dbPath` + +### 进程模型 + +- 单个二进制,通过 Cobra 子命令 `serve` 启动 +- 监听 `SIGINT` / `SIGTERM` 做优雅退出 +- scheduler 与 HTTP server 在同一进程内运行 +- 路由层使用 `gorilla/mux` + +### 定时任务 + +- 每天 `0 5 * * *` 执行一次 poo records 与 Notion 的同步 +- 定义在 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180) + +### 被动接收式接口 + +- 上面列出的入站 REST API +- TickTick OAuth callback endpoint + +### 运行时依赖的外部服务 + +- Home Assistant HTTP API 与 webhook +- TickTick OAuth 与 REST API +- 当前 poo 流程仍依赖 Notion API +- 本地可写文件系统,用于配置文件和 SQLite DB + +### 当前本地 / 服务部署形态 + +- 安装脚本会构建 Go 二进制,并安装到 `$HOME/.local/home-automation-backend` +- 使用 Supervisor 管理进程 +- 生成的 supervisor 配置最终执行 `{binary} serve` +- 参考: + - [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45) + - [`helper/home_automation_backend_template.conf`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/home_automation_backend_template.conf:1) + +### 容器化情况 + +- 这一轮代码扫描中没有发现 `Dockerfile` 或 compose 文件 +- 当前部署形态是 supervisor-based,而不是 container-based + +## 测试与文档现状 + +### 测试现状 + +- 只发现一个测试文件:[`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1) +- 当前测试覆盖: + - 入站命令 JSON 解码 + - target/action 路由分发 + - 转发到 poo/location handler 的行为 + - TickTick task 创建委托 + - 错误日志和失败路径 +- 当前没有覆盖: + - poo recorder 的 DB 行为 + - location recorder 的 DB 行为 + - TickTick OAuth 流程 + - Home Assistant 出站发布 + - Notion sync 逻辑 + - 启动与配置加载 + - scheduler 行为 + +### 本轮测试执行情况 + +- 我尝试在 `legacy/go-backend/src/` 下执行 `go test ./...` +- 但当前会话环境里没有安装 `go` 命令,因此无法实际运行测试 +- 所以这轮关于测试的判断,基于静态阅读,而不是实际执行结果 + +### 文档现状 + +- 仓库里的 `README.md` 基本只有标题和 badge,内容非常少 +- 没有用户可读的 API 文档 +- 没有 schema 文档 +- 没有 Home Assistant / TickTick 的契约说明文档 +- 没有关于配置项、OAuth 初始化、数据库文件位置的运维文档 + +### 后续最需要补齐的文档 + +- Home Assistant 命令 envelope 与支持的 action +- 各 API 的 request / response 契约 +- TickTick OAuth 初始化与 token 持久化方式 +- 数据库归属、用途与保留策略 +- Notion 下线 / 不迁移说明 + +## 对 Python 重构特别重要的事实 + +- 当前 API 行为整体比较“轻响应、重副作用”,很多成功请求返回的都是空响应体 +- 当前所有入站 API 都没有看到鉴权 +- 当前系统本地真相来源是 SQLite,但 poo 数据还同时与 Notion 同步 +- `notion.token` 现在不是可选项,缺失时服务会在 `initUtil()` 阶段直接退出 +- Home Assistant 命令路由当前是通过本地 HTTP 自调用实现的,而不是直接服务层调用 +- TickTick callback 会改写应用本身使用的 YAML 配置文件 diff --git a/docs/migration-notes.md b/docs/migration-notes.md new file mode 100644 index 0000000..5305177 --- /dev/null +++ b/docs/migration-notes.md @@ -0,0 +1,40 @@ +# Migration Notes + +本文档记录 Python skeleton 阶段的迁移说明,帮助后续继续推进时快速恢复上下文。 + +## 当前阶段完成内容 + +- 建立 FastAPI 应用骨架 +- 建立环境变量配置体系 +- 接入 SQLAlchemy 与 Alembic +- 建立 Jinja2 模板基础 +- 建立 pytest 基础设施 +- 建立 Docker / Compose 基础骨架 +- 建立 OpenAPI 导出脚本 + +## 当前阶段未做内容 + +- 未迁移 TickTick 业务逻辑 +- 未迁移 Home Assistant 业务逻辑 +- 未迁移 poo records +- 未迁移 location / life trajectory +- 未实现真实 OAuth 流程 +- 未做数据迁移 + +## 后续建议顺序 + +建议继续沿用既有迁移文档中的顺序: + +1. 先迁 `location recorder` +2. 再迁 Home Assistant 出站适配层 +3. 再迁 TickTick adapter +4. 再迁 Home Assistant 命令网关 +5. 最后迁 `poo recorder` + +## 开发约束提醒 + +- 保持对当前 Go 外部行为的兼容意识 +- 不要把旧 Python 版本当作设计基线 +- 不要重新引入 Notion 作为 Python 主系统能力 +- 在迁业务模块时,优先补 contract tests + diff --git a/docs/migration-risks.md b/docs/migration-risks.md new file mode 100644 index 0000000..209cde0 --- /dev/null +++ b/docs/migration-risks.md @@ -0,0 +1,238 @@ +# 迁移风险清单 + +本文档列出将当前 Go 后端重构为 Python 时的主要风险点。这里的风险判断,默认都是以“当前 Go 行为需要尽量兼容”为前提。 + +## 最高风险区域 + +### 1. `POST /homeassistant/publish` 存在隐式行为契约 + +风险: + +- 当前 Home Assistant 网关使用 `target`、`action`、`content` 这种 envelope +- `content` 不是标准嵌套 JSON,而是字符串化 payload +- 有些 payload 明显依赖单引号 pseudo-JSON,再在代码中做归一化 + +为什么重要: + +- 如果 Python 版过早改成“严格、干净、标准化的嵌套 JSON”,现有 Home Assistant automations 可能直接失效 + +当前证据: + +- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82) +- 测试见 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:129) + +缓解建议: + +- 在重构前先收集真实线上 payload 样例 +- 第一阶段保留兼容性解析 +- 为所有当前支持的 `target/action` 增加回归测试 + +### 2. TickTick OAuth 与 token 持久化流程 + +风险: + +- OAuth `state` 当前只保存在进程内 +- token 获取后会直接写回 YAML 配置文件 + +为什么重要: + +- Python 重构很容易在不自觉的情况下改变操作流程 +- token 持久化语义一变,可能会带来难排查的鉴权失败 + +当前证据: + +- callback 与配置回写逻辑见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103) +- 授权 URL 初始化见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:275) + +缓解建议: + +- 在编码前先确定 token storage 方案 +- 保持 callback 契约稳定 +- 在 staging 环境用真实 TickTick app 做端到端验证 + +### 3. Poo recorder 的副作用比 API 表面看起来更复杂 + +风险: + +- `POST /poo/record` 不只是写一条 DB +- 它还会镜像到 Notion、发布 Home Assistant sensor、并且可能触发 Home Assistant webhook + +为什么重要: + +- 即使 Python 版 API 看起来兼容,如果漏掉这些副作用,也会导致真实自动化行为偏差 + +当前证据: + +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57) +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:97) +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:339) + +缓解建议: + +- 把 endpoint 契约和 side-effect 契约分开写清楚 +- 在 Python 中通过显式 service / adapter 接口承接这些行为 +- 用 mock Home Assistant / TickTick / Notion 的方式做测试 + +### 4. 移除 Notion 会改变当前运行预期 + +风险: + +- Notion 虽然已经被识别为“不计划继续保留”,但它现在并不是边缘代码 +- 它当前参与启动、请求处理以及每日同步 + +为什么重要: + +- 去掉 Notion 会实质改变数据流 +- 也可能影响历史镜像、人工运维方式以及启动要求 + +当前证据: + +- 启动时强依赖 `notion.token`,见 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:41) +- 每日同步逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191) +- helper CLI 见 [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) + +缓解建议: + +- 在文档里显式说明 Notion 将被下线 / 不迁移 +- 先决定是否需要一次性历史导出或回填 +- 确保移除 Notion 后,`pooRecorder.tableId` 和 `notion.token` 不再阻塞服务启动 + +## 中等风险区域 + +### 5. SQLite 兼容性与时间戳格式 + +风险: + +- 当前代码把时间戳以文本形式存储 +- `location` 和 `poo` 两个模块使用的时间格式并不相同 + +为什么重要: + +- Python 重构若擅自统一时间格式,可能会破坏旧 DB 的可兼容读取 + +当前证据: + +- poo 时间戳写入逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:344) +- location 时间戳写入逻辑见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:61) + +缓解建议: + +- 先决定 Python 第一阶段是否直接复用现有 DB 文件 +- 如果要复用,就要保留当前时间戳序列化行为 +- 为数据层建立回归样例 + +### 6. 输入校验行为可能与 FastAPI 默认习惯冲突 + +风险: + +- FastAPI / Pydantic 通常更倾向严格校验 +- 当前 Go 代码的校验行为并不一致: + - 有些接口拒绝 unknown fields + - `location` 数值解析错误会被静默忽略 + - 很多成功响应是空响应体 + +为什么重要: + +- 更“正确”的校验,也可能是破坏兼容性的改动 + +当前证据: + +- 多个 handler 都开启了严格字段检查 +- `location` 的静默 float parsing 见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:54) + +缓解建议: + +- 先明确哪些怪异行为是必须兼容的,哪些可以修正 +- 第一阶段如果要保持兼容,可以在 Python 里用自定义校验逻辑模拟当前行为 + +### 7. 定时任务行为漂移 + +风险: + +- 当前应用内嵌了一个每天 `0 5 * * *` 执行的 Notion 同步任务 + +为什么重要: + +- 如果 Python 版仍保留类似行为,时区、执行时机、幂等性处理差异都可能导致重复或漏同步 + +当前证据: + +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180) + +缓解建议: + +- 如果 Notion 被移除,就应有意识地同步移除 scheduler 相关逻辑,并写清楚原因 +- 如果在过渡期暂时保留,就要明确 timezone 与幂等语义 + +### 8. self-HTTP 编排改成 direct service calls 的差异 + +风险: + +- 当前 Home Assistant 网关通过调用 `localhost` 上的本地接口来驱动其它模块 + +为什么重要: + +- 改成直接函数 / service 调用本身是合理的,但可能改变 timeout、错误传播和日志行为 + +当前证据: + +- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:88) +- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:115) + +缓解建议: + +- 对外 HTTP 行为保持不变 +- 但在内部重写后,补足状态码和失败语义测试 + +## 风险较低但仍重要的区域 + +### 9. 部署模型变化 + +风险: + +- 当前部署方式是 supervisor-based +- 未来目标是容器化 + +为什么重要: + +- 启动文件路径、配置文件写入位置、token persistence 方式,在容器环境下都可能出问题 + +当前证据: + +- 安装脚本见 [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45) + +缓解建议: + +- 把运行时状态从镜像内容中解耦 +- 预先定义 DB/config 是否需要挂载 volume +- 在 cutover 前先写清楚 container env vars 与文件挂载约定 + +### 10. 现有测试过少 + +风险: + +- 当前大多数模块没有自动化测试 + +为什么重要: + +- 没有安全网时,重构很容易改坏行为而不自知 + +当前证据: + +- 当前只发现 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1) + +缓解建议: + +- 把 contract test 建设视作迁移工作的一部分,而不是迁移后的补票 +- 每迁一个模块,就同步补该模块的测试 + +## 总结 + +这次重构最大的风险,不是“Go 改 Python”本身,而是几个隐藏得很深的行为契约: + +- Home Assistant 命令 payload 的真实格式 +- TickTick 的 OAuth / token 生命周期 +- poo recorder 的一组副作用行为 +- 当前仍活跃、但计划下线的 Notion 耦合 + +只要先把这些契约写清楚、测清楚,再开始 Python 实现,整个重构路线就会可控很多。 diff --git a/docs/python-rewrite-plan.md b/docs/python-rewrite-plan.md new file mode 100644 index 0000000..fcec9c2 --- /dev/null +++ b/docs/python-rewrite-plan.md @@ -0,0 +1,314 @@ +# Python 重构方案 + +本文档基于当前 Go 实现,给出迁移到 Python + FastAPI 的设计输入与建议顺序。本文档只讨论迁移方案,不代表已经开始实现。 + +## 重构原则 + +- 以当前 Go 实现为唯一事实来源 +- 先保持对外行为兼容,再考虑内部清理和优化 +- 明确区分“行为兼容”和“内部实现升级” +- 默认不把 Notion 纳入新的 Python 主版本目标,除非后续重新决策 +- 尽量按模块迁移,并在模块之间建立清晰契约 + +## 目标形态 + +建议的 Python 目标架构: + +- 用 FastAPI 提供 HTTP 路由,并自然生成 OpenAPI +- 业务逻辑按模块拆分到 service layer +- 外部系统对接放到 adapter layer +- SQLite 访问放到 repository layer +- 配置采用显式 settings model +- scheduler 是否内嵌在应用进程内,需要在启动语义明确后再决定 +- 仅保留极轻量的服务端页面,用于 OAuth 跳转或简单配置 + +## 建议的 Python 模块边界 + +### 应用外壳层 + +- 职责: + - 读取 settings + - 依赖注入与对象装配 + - 路由注册 + - lifespan hooks + - scheduler 启停 +- 在 FastAPI 中可对应: + - `main.py` 或 app factory + - settings 类 + +### Poo 领域模块 + +- 职责: + - 校验 poo record 输入 + - 持久化 poo record + - 查询 latest poo + - 通过接口触发外部副作用 +- 建议把副作用依赖抽象为端口: + - `PooRepository` + - `HomeAssistantPublisher` + - `HomeAssistantWebhookClient` + - 如有需要,可保留临时 `LegacyPooMirror` 作为 Notion 过渡适配器 + +### Location 领域模块 + +- 职责: + - 校验并持久化位置点 +- 可保持简洁: + - `LocationRepository` + - `LocationService` + +### Home Assistant 命令网关 + +- 职责: + - 暴露 `/homeassistant/publish` + - 解析 `target/action/content` + - 将命令分发到内部服务 +- 兼容性注意: + - 第一阶段应保留当前 `content` 的处理习惯,包括对字符串 payload 的兼容解析 + +### TickTick 集成模块 + +- 职责: + - OAuth start / callback + - token 存储 + - task 查询 + - 去重 + - task 创建 +- 建议: + - 把 token persistence 抽象成独立能力,而不是把“改写配置文件”直接塞进业务逻辑 + +### Home Assistant 出站适配层 + +- 职责: + - 发布 sensor state + - 触发 webhook +- 该层小而独立,适合较早迁移 + +### Legacy Notion 适配层 + +- 职责: + - 只在分析或过渡阶段表示当前行为 +- 默认建议: + - 不放入 Python 第一版正式目标 + - 如 cutover 期间确有需要,可以 feature flag 或独立迁移工具的方式暂存 + +## 实现前需要冻结的兼容契约 + +在正式编码前,建议先把以下当前行为写成明确契约: + +### API 契约 + +- `POST /poo/record` 的请求字段与当前“成功时空响应体”的行为 +- `POST /location/record` 的请求字段与数值解析行为 +- `POST /homeassistant/publish` 的 envelope 格式与支持的 `target/action` +- `GET /ticktick/auth/code` 的成功 / 失败语义 + +### 副作用契约 + +- `POST /poo/record` 在什么时机会发布 Home Assistant sensor +- `POST /poo/record` 在什么条件下会触发 Home Assistant webhook +- Home Assistant 消息如何映射为 TickTick task title 与 due date +- Home Assistant sensor payload 的结构 + +### 持久化契约 + +- 当前 SQLite 表名与主键 +- 当前磁盘上的时间戳格式 +- Python 第一阶段是否直接复用现有 DB 文件,还是做显式迁移 + +## 建议的迁移决策 + +### 决策 1:第一阶段保持对外 API 形状不变 + +原因: + +- 当前 API 面很小 +- 保持兼容能显著降低切换风险 +- 即使保持兼容,FastAPI 仍然可以生成 OpenAPI 文档 + +### 决策 2:把内部 self-HTTP 改成直接服务调用 + +原因: + +- 当前 Go 代码中的 `localhost` 自调用,本质上是内部编排手段 +- 这不是一个必须暴露给外部的契约 +- Python 版改为直接函数 / service 调用,可以提升清晰度和可测试性 + +### 决策 3:先继续使用 SQLite + +原因: + +- 当前系统已经使用 SQLite +- 数据模型规模很小 +- PostgreSQL 更适合作为 parity 之后的下一阶段演进 + +### 决策 4:默认不迁 Notion,但要明确记录影响 + +原因: + +- 你已经明确表示 Notion 很可能不继续保留 +- 当前 Notion 不是“代码里有但没在用”,而是真正参与运行逻辑 +- 所以不能静默删除,而要在方案中写清楚删掉后有什么影响 + +### 决策 5:把 token / auth persistence 做成显式设计 + +原因: + +- 当前 TickTick token 处理虽然可用,但运维上比较脆弱 +- Python 重构是一个把这件事规范化的机会 + +## 建议迁移顺序 + +### Phase 0:盘点与契约确认 + +- 完成当前系统 inventory +- 确认哪些当前行为是“必须兼容的契约”,哪些只是历史偶然实现 +- 明确把 Notion 标为 non-migration scope,除非后续重新决定 + +### Phase 1:Python 骨架与通用基础设施 + +- 建立 FastAPI app shell +- 定义 settings / config model +- 定义日志方案 +- 定义 SQLite 访问方式 +- 定义测试框架与 fixture 策略 +- 定义 OpenAPI 生成与导出方式 + +这一阶段不需要大量迁移业务逻辑,只要搭好后续模块可持续迁入的基础即可。 + +### Phase 2:先迁最独立、最稳定的业务模块 + +推荐优先迁移:`location recorder` + +原因: + +- 独立 SQLite 表 +- 没有复杂外部副作用 +- 没有 OAuth +- 没有 scheduler + +这一阶段的交付物可以包括: + +- `POST /location/record` +- 与现有 SQLite 兼容的写入逻辑 +- 校验与 repository 的单元测试 +- 基于临时 SQLite 的 integration test + +### Phase 3:迁移 Home Assistant 出站适配层 + +原因: + +- 功能面小 +- 能为后面的 poo 迁移做铺垫 + +这一阶段的交付物可以包括: + +- sensor publish client +- webhook trigger client +- 针对请求格式与错误处理的 mock tests + +### Phase 4:迁移 TickTick adapter + +原因: + +- 相对自洽 +- 在完成 Home Assistant 命令网关前就需要它 + +这一阶段的交付物可以包括: + +- OAuth callback endpoint +- token persistence abstraction +- task 创建与去重行为 +- 基于 mock HTTP 的集成式测试 + +### Phase 5:迁移 Home Assistant 命令网关 + +原因: + +- 这是外部 automations 的核心编排入口 +- 在 location 与 TickTick adapter 准备好后,网关迁移会顺很多 + +这一阶段的交付物可以包括: + +- `/homeassistant/publish` +- 兼容当前 `target/action` 的分发逻辑 +- 用进程内 service 调用替代 self-HTTP +- 把现有 Go 测试场景迁成 Python contract tests + +### Phase 6:迁移 poo recorder 核心,但默认不带 Notion + +原因: + +- 这是最复杂的模块 +- 它既有本地 DB,又有 Home Assistant 副作用,当前还耦合 Notion + +建议拆成两个子阶段: + +- phase 6a: + - 本地 poo DB + - latest poo 查询 + - sensor publish + - 可选 webhook trigger + - `/poo/record` + - `/poo/latest` + +- phase 6b: + - 如果 cutover 期间必须保留旧逻辑,再做一个临时 legacy Notion 兼容层 + +### Phase 7:运维加固与切换验证 + +- 做 Go / Python 路由级契约比对 +- 用现有 SQLite 文件或其副本做兼容验证 +- 在 staging 环境手动验证 TickTick OAuth +- 用真实 Home Assistant automation payload 做验证 +- 导出 OpenAPI YAML +- 再补容器化与部署方案 + +## 哪些模块适合先迁,哪些适合后迁 + +### 适合优先迁移 + +- location recorder +- Home Assistant 出站 client +- TickTick adapter + +### 更适合后迁 + +- Home Assistant 命令网关 +- poo recorder 核心 + +### 现状存在,但建议不迁 + +- Notion sync adapter +- `poo_recorder_helper` 的 Notion 表反转 CLI +- `location_recorder` helper CLI 脚手架 + +## 建议的验证策略 + +### Contract tests + +- 基于当前 Go 行为建立 request / response fixtures +- 先把现有 `homeassistant` 测试案例迁成 Python +- 补上 `poo` 与 `location` API 的契约测试 + +### Integration tests + +- 每个模块使用临时 SQLite DB +- Home Assistant 与 TickTick 出站流量通过 mock HTTP 替代 +- 若仍保留 scheduler,则为其补定时行为测试 + +### 手工 staging 验证 + +- TickTick OAuth callback +- Home Assistant sensor 更新 +- Home Assistant webhook 触发 +- 当前真实自动化 payload 样例 + +## 开始实现前仍需明确的问题 + +- Python 第一阶段是否还要保留“缺少 `notion.token` 就启动失败”的行为,还是直接把 Notion 变成可关闭能力? +- `POST /location/record` 是否要继续保留“非法数字静默变成 0”的兼容行为? +- TickTick token 在第一阶段是否继续写回 YAML,还是立即切到独立 token store? +- 当前 Home Assistant automations 是否真实依赖 `content` 中的单引号 pseudo-JSON? + +这些问题不影响当前 inventory,但会影响第一阶段“兼容到什么程度”的具体定义。 diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..e5b1b42 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,18 @@ +# Legacy Code + +这个目录用于收纳 Python 重构开始之前就已存在的旧实现与配套资产,方便在重构完成后整块删除。 + +当前已迁入: + +- `go-backend/src/` + - 旧 Go 后端实现 +- `go-backend/helper/` + - 旧 Go 部署与辅助脚本 +- `go-backend/.github/workflows/` + - 旧 Go 版本对应的 GitHub Actions workflows + +原则上: + +- 新的 Python 实现继续在仓库根目录的 `app/`、`tests/`、`alembic/` 等目录演进 +- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础 +- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/` diff --git a/.github/workflows/nightly.yml b/legacy/go-backend/.github/workflows/nightly.yml similarity index 100% rename from .github/workflows/nightly.yml rename to legacy/go-backend/.github/workflows/nightly.yml diff --git a/.github/workflows/short-tests.yml b/legacy/go-backend/.github/workflows/short-tests.yml similarity index 100% rename from .github/workflows/short-tests.yml rename to legacy/go-backend/.github/workflows/short-tests.yml diff --git a/helper/home_automation_backend_template.conf b/legacy/go-backend/helper/home_automation_backend_template.conf similarity index 100% rename from helper/home_automation_backend_template.conf rename to legacy/go-backend/helper/home_automation_backend_template.conf diff --git a/helper/install.sh b/legacy/go-backend/helper/install.sh similarity index 100% rename from helper/install.sh rename to legacy/go-backend/helper/install.sh diff --git a/src/LICENSE b/legacy/go-backend/src/LICENSE similarity index 100% rename from src/LICENSE rename to legacy/go-backend/src/LICENSE diff --git a/src/cmd/root.go b/legacy/go-backend/src/cmd/root.go similarity index 100% rename from src/cmd/root.go rename to legacy/go-backend/src/cmd/root.go diff --git a/src/cmd/serve.go b/legacy/go-backend/src/cmd/serve.go similarity index 100% rename from src/cmd/serve.go rename to legacy/go-backend/src/cmd/serve.go diff --git a/src/components/homeassistant/homeassistant.go b/legacy/go-backend/src/components/homeassistant/homeassistant.go similarity index 100% rename from src/components/homeassistant/homeassistant.go rename to legacy/go-backend/src/components/homeassistant/homeassistant.go diff --git a/src/components/homeassistant/homeassistant_test.go b/legacy/go-backend/src/components/homeassistant/homeassistant_test.go similarity index 100% rename from src/components/homeassistant/homeassistant_test.go rename to legacy/go-backend/src/components/homeassistant/homeassistant_test.go diff --git a/src/components/locationRecorder/locationRecorder.go b/legacy/go-backend/src/components/locationRecorder/locationRecorder.go similarity index 100% rename from src/components/locationRecorder/locationRecorder.go rename to legacy/go-backend/src/components/locationRecorder/locationRecorder.go diff --git a/src/components/pooRecorder/pooRecorder.go b/legacy/go-backend/src/components/pooRecorder/pooRecorder.go similarity index 100% rename from src/components/pooRecorder/pooRecorder.go rename to legacy/go-backend/src/components/pooRecorder/pooRecorder.go diff --git a/src/go.mod b/legacy/go-backend/src/go.mod similarity index 100% rename from src/go.mod rename to legacy/go-backend/src/go.mod diff --git a/src/go.sum b/legacy/go-backend/src/go.sum similarity index 100% rename from src/go.sum rename to legacy/go-backend/src/go.sum diff --git a/src/helper/location_recorder/LICENSE b/legacy/go-backend/src/helper/location_recorder/LICENSE similarity index 100% rename from src/helper/location_recorder/LICENSE rename to legacy/go-backend/src/helper/location_recorder/LICENSE diff --git a/src/helper/location_recorder/cmd/addgpx.go b/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go similarity index 100% rename from src/helper/location_recorder/cmd/addgpx.go rename to legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go diff --git a/src/helper/location_recorder/cmd/root.go b/legacy/go-backend/src/helper/location_recorder/cmd/root.go similarity index 100% rename from src/helper/location_recorder/cmd/root.go rename to legacy/go-backend/src/helper/location_recorder/cmd/root.go diff --git a/src/helper/location_recorder/main.go b/legacy/go-backend/src/helper/location_recorder/main.go similarity index 100% rename from src/helper/location_recorder/main.go rename to legacy/go-backend/src/helper/location_recorder/main.go diff --git a/src/helper/poo_recorder_helper/LICENSE b/legacy/go-backend/src/helper/poo_recorder_helper/LICENSE similarity index 100% rename from src/helper/poo_recorder_helper/LICENSE rename to legacy/go-backend/src/helper/poo_recorder_helper/LICENSE diff --git a/src/helper/poo_recorder_helper/cmd/reverse.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go similarity index 100% rename from src/helper/poo_recorder_helper/cmd/reverse.go rename to legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go diff --git a/src/helper/poo_recorder_helper/cmd/root.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go similarity index 100% rename from src/helper/poo_recorder_helper/cmd/root.go rename to legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go diff --git a/src/helper/poo_recorder_helper/main.go b/legacy/go-backend/src/helper/poo_recorder_helper/main.go similarity index 100% rename from src/helper/poo_recorder_helper/main.go rename to legacy/go-backend/src/helper/poo_recorder_helper/main.go diff --git a/src/main.go b/legacy/go-backend/src/main.go similarity index 100% rename from src/main.go rename to legacy/go-backend/src/main.go diff --git a/src/util/homeassistantutil/homeassistantutil.go b/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go similarity index 100% rename from src/util/homeassistantutil/homeassistantutil.go rename to legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go diff --git a/src/util/notion/notion.go b/legacy/go-backend/src/util/notion/notion.go similarity index 100% rename from src/util/notion/notion.go rename to legacy/go-backend/src/util/notion/notion.go diff --git a/src/util/ticktickutil/ticktickutil.go b/legacy/go-backend/src/util/ticktickutil/ticktickutil.go similarity index 100% rename from src/util/ticktickutil/ticktickutil.go rename to legacy/go-backend/src/util/ticktickutil/ticktickutil.go diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3eb7047 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "home-automation-python" +version = "0.1.0" +description = "Python rewrite skeleton for the home automation backend." +readme = "README.md" +requires-python = ">=3.11" + +[tool.setuptools] +packages = [ + "app", + "app.api", + "app.api.routes", + "app.integrations", + "app.models", + "app.schemas", + "app.services", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..ce81f3c --- /dev/null +++ b/requirements.in @@ -0,0 +1,8 @@ +alembic>=1.14,<2.0 +fastapi>=0.115,<0.116 +jinja2>=3.1,<4.0 +pydantic-settings>=2.6,<3.0 +python-multipart>=0.0.12,<1.0 +pyyaml>=6.0,<7.0 +sqlalchemy>=2.0,<3.0 +uvicorn[standard]>=0.32,<1.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c95bd7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,78 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile requirements.in +# +alembic==1.18.4 + # via -r requirements.in +annotated-types==0.7.0 + # via pydantic +anyio==4.13.0 + # via + # starlette + # watchfiles +click==8.3.2 + # via uvicorn +fastapi==0.115.14 + # via -r requirements.in +greenlet==3.4.0 + # via sqlalchemy +h11==0.16.0 + # via uvicorn +httptools==0.7.1 + # via uvicorn +idna==3.11 + # via anyio +jinja2==3.1.6 + # via -r requirements.in +mako==1.3.11 + # via alembic +markupsafe==3.0.3 + # via + # jinja2 + # mako +pydantic==2.13.2 + # via + # fastapi + # pydantic-settings +pydantic-core==2.46.2 + # via pydantic +pydantic-settings==2.13.1 + # via -r requirements.in +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.26 + # via -r requirements.in +pyyaml==6.0.3 + # via + # -r requirements.in + # uvicorn +sqlalchemy==2.0.49 + # via + # -r requirements.in + # alembic +starlette==0.46.2 + # via fastapi +typing-extensions==4.15.0 + # via + # alembic + # fastapi + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-settings +uvicorn[standard]==0.44.0 + # via -r requirements.in +uvloop==0.22.1 + # via uvicorn +watchfiles==1.1.1 + # via uvicorn +websockets==16.0 + # via uvicorn diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..1adb17a --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,27 @@ +from pathlib import Path + +import yaml + +from app.main import create_app + + +def main() -> None: + app = create_app() + output_dir = Path("openapi") + output_dir.mkdir(parents=True, exist_ok=True) + + schema = app.openapi() + + json_path = output_dir / "openapi.json" + yaml_path = output_dir / "openapi.yaml" + + json_path.write_text(__import__("json").dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8") + yaml_path.write_text(yaml.safe_dump(schema, allow_unicode=True, sort_keys=False), encoding="utf-8") + + print(f"Wrote {json_path}") + print(f"Wrote {yaml_path}") + + +if __name__ == "__main__": + main() + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4f8484c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app + + +@pytest.fixture +def app(): + return create_app() + + +@pytest.fixture +def client(app): + with TestClient(app) as test_client: + yield test_client diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..b6140f2 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + + +def test_app_starts(client: TestClient) -> None: + response = client.get("/") + assert response.status_code == 200 + + +def test_status_endpoint(client: TestClient) -> None: + response = client.get("/status") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + From 32cc6847fdb16e2d0bff639e0a3946e6a22b02da Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 21:39:23 +0200 Subject: [PATCH 02/17] Migrate location recorder and refine db config --- .env.example | 4 +- .gitignore | 2 +- README.md | 27 ++- alembic.ini | 4 +- alembic/env.py | 6 +- .../versions/20260419_01_location_baseline.py | 33 ++++ app/api/routes/location.py | 28 +++ app/config.py | 25 ++- app/db.py | 5 +- app/main.py | 9 +- app/models/__init__.py | 3 + app/models/location.py | 15 ++ app/schemas/location.py | 11 ++ app/services/location.py | 36 ++++ docker-compose.yml | 4 +- docs/location-recorder.md | 96 ++++++++++ docs/migration-notes.md | 43 ++++- tests/test_config.py | 15 ++ tests/test_location.py | 172 ++++++++++++++++++ 19 files changed, 507 insertions(+), 31 deletions(-) create mode 100644 alembic/versions/20260419_01_location_baseline.py create mode 100644 app/api/routes/location.py create mode 100644 app/models/location.py create mode 100644 app/schemas/location.py create mode 100644 app/services/location.py create mode 100644 docs/location-recorder.md create mode 100644 tests/test_config.py create mode 100644 tests/test_location.py diff --git a/.env.example b/.env.example index d9c7351..4abd352 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ APP_ENV=development APP_DEBUG=true APP_HOST=0.0.0.0 APP_PORT=8000 -DATABASE_URL=sqlite:///./data/app.db +LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db +POO_DATABASE_URL=sqlite:///./data/pooRecorder.db TICKTICK_CLIENT_ID= TICKTICK_CLIENT_SECRET= TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback @@ -11,4 +12,3 @@ TICKTICK_TOKEN= HOME_ASSISTANT_BASE_URL=http://localhost:8123 HOME_ASSISTANT_AUTH_TOKEN= HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= - diff --git a/.gitignore b/.gitignore index 1d5c43b..517a4a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.codex .env .pytest_cache/ .venv/ @@ -5,4 +6,3 @@ __pycache__/ *.pyc data/ openapi/ - diff --git a/README.md b/README.md index 2e20eda..ffaed7f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - 环境变量配置体系 - SQLite + SQLAlchemy + Alembic 基础设施 - 极简 server-side templates +- location recorder 第一版迁移 - pytest 测试基础 - OpenAPI 导出脚本 - Docker / Compose 基础骨架 @@ -19,7 +20,6 @@ - TickTick 业务逻辑迁移 - Home Assistant 业务逻辑迁移 - poo records 业务迁移 -- location / life trajectory 业务迁移 - Notion 模块 Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 @@ -30,6 +30,20 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco - `legacy/go-backend/helper/` - `legacy/go-backend/.github/workflows/` +## 当前配置现实 + +当前系统仍然是两个独立的 SQLite 数据库文件,而不是单一数据库: + +- `location` 模块使用自己的 DB 文件 +- `poo` 模块未来也将使用自己的 DB 文件 + +当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点: + +- `LOCATION_DATABASE_URL` +- `POO_DATABASE_URL` + +目前真正接入的是 `location` 对应的数据库;`poo` 先保留配置占位,等模块迁入时再接上。 + ## 当前目录 Python 骨架的主要目录如下: @@ -100,9 +114,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 数据库与 Alembic -当前默认数据库使用 SQLite。 +当前默认仍使用 SQLite,但要明确区分两个数据库文件: -- 默认数据库地址:`sqlite:///./data/app.db` +- Location DB:`sqlite:///./data/locationRecorder.db` +- Poo DB:`sqlite:///./data/pooRecorder.db` - 数据目录:`./data/` 初始化 migration 环境后,可继续添加模型并生成迁移: @@ -112,7 +127,7 @@ alembic revision --autogenerate -m "init tables" alembic upgrade head ``` -这一轮尚未引入业务表,因此 Alembic 目前主要是基础设施就绪状态。 +当前 Alembic 只接管 `location` 这条链路;`poo` 相关数据库与 migration 还没有迁入。 ## 运行测试 @@ -163,15 +178,15 @@ SQLite 持久化目录: ## 后续迁移建议 -后续可以在当前骨架上逐步迁移这些模块: +后续可以在当前骨架上继续迁移这些模块: - TickTick integration - Home Assistant integration - poo records -- location / life trajectory 建议继续参考: - [当前系统盘点](docs/current-system-inventory.md) - [Python 重构方案](docs/python-rewrite-plan.md) - [迁移风险清单](docs/migration-risks.md) +- [Location Recorder 接管说明](docs/location-recorder.md) diff --git a/alembic.ini b/alembic.ini index 7c7143b..b385015 100644 --- a/alembic.ini +++ b/alembic.ini @@ -1,7 +1,8 @@ [alembic] script_location = alembic prepend_sys_path = . -sqlalchemy.url = sqlite:///./data/app.db +path_separator = os +sqlalchemy.url = sqlite:///./data/locationRecorder.db [loggers] keys = root,sqlalchemy,alembic @@ -34,4 +35,3 @@ formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s - diff --git a/alembic/env.py b/alembic/env.py index 7fa3bc7..5b2d901 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -4,6 +4,7 @@ from alembic import context from sqlalchemy import engine_from_config, pool from app.config import get_settings +from app.models import Location # noqa: F401 from app.models.base import Base config = context.config @@ -12,7 +13,9 @@ if config.config_file_name is not None: fileConfig(config.config_file_name) settings = get_settings() -config.set_main_option("sqlalchemy.url", settings.database_url) +configured_url = config.get_main_option("sqlalchemy.url") +if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db": + config.set_main_option("sqlalchemy.url", settings.location_database_url) target_metadata = Base.metadata @@ -43,4 +46,3 @@ if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() - diff --git a/alembic/versions/20260419_01_location_baseline.py b/alembic/versions/20260419_01_location_baseline.py new file mode 100644 index 0000000..cc94da5 --- /dev/null +++ b/alembic/versions/20260419_01_location_baseline.py @@ -0,0 +1,33 @@ +"""location baseline + +Revision ID: 20260419_01_location_baseline +Revises: +Create Date: 2026-04-19 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260419_01_location_baseline" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "location", + sa.Column("person", sa.Text(), nullable=False), + sa.Column("datetime", sa.Text(), nullable=False), + sa.Column("latitude", sa.Float(), nullable=False), + sa.Column("longitude", sa.Float(), nullable=False), + sa.Column("altitude", sa.Float(), nullable=True), + sa.PrimaryKeyConstraint("person", "datetime"), + ) + + +def downgrade() -> None: + op.drop_table("location") diff --git a/app/api/routes/location.py b/app/api/routes/location.py new file mode 100644 index 0000000..33132be --- /dev/null +++ b/app/api/routes/location.py @@ -0,0 +1,28 @@ +import json + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import PlainTextResponse, Response +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.dependencies import get_db +from app.schemas.location import LocationRecordRequest +from app.services.location import record_location + +router = APIRouter(tags=["location"]) + + +@router.post("/location/record") +async def create_location_record(request: Request, db: Session = Depends(get_db)) -> Response: + try: + raw_payload = await request.body() + data = json.loads(raw_payload) + payload = LocationRecordRequest.model_validate(data) + except json.JSONDecodeError as exc: + return PlainTextResponse(str(exc), status_code=400) + except ValidationError as exc: + return PlainTextResponse(str(exc), status_code=400) + + record_location(db, payload) + return Response(status_code=200) + diff --git a/app/config.py b/app/config.py index d42e2d4..021aa8a 100644 --- a/app/config.py +++ b/app/config.py @@ -12,7 +12,8 @@ class Settings(BaseSettings): app_host: str = "0.0.0.0" app_port: int = 8000 - database_url: str = "sqlite:///./data/app.db" + location_database_url: str = "sqlite:///./data/locationRecorder.db" + poo_database_url: str = "sqlite:///./data/pooRecorder.db" ticktick_client_id: str = "" ticktick_client_secret: str = "" @@ -34,17 +35,25 @@ class Settings(BaseSettings): def is_development(self) -> bool: return self.app_env.lower() == "development" + @staticmethod + def _sqlite_path_from_url(database_url: str) -> Path | None: + prefix = "sqlite:///" + if not database_url.startswith(prefix): + return None + raw_path = database_url[len(prefix) :] + return Path(raw_path) + @computed_field @property - def sqlite_path(self) -> Path | None: - prefix = "sqlite:///" - if not self.database_url.startswith(prefix): - return None - raw_path = self.database_url[len(prefix) :] - return Path(raw_path) + def location_sqlite_path(self) -> Path | None: + return self._sqlite_path_from_url(self.location_database_url) + + @computed_field + @property + def poo_sqlite_path(self) -> Path | None: + return self._sqlite_path_from_url(self.poo_database_url) @lru_cache def get_settings() -> Settings: return Settings() - diff --git a/app/db.py b/app/db.py index efbb463..c8d94a9 100644 --- a/app/db.py +++ b/app/db.py @@ -13,10 +13,10 @@ class Base(DeclarativeBase): settings = get_settings() connect_args: dict[str, object] = {} -if settings.database_url.startswith("sqlite"): +if settings.location_database_url.startswith("sqlite"): connect_args["check_same_thread"] = False -engine = create_engine(settings.database_url, connect_args=connect_args) +engine = create_engine(settings.location_database_url, connect_args=connect_args) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) @@ -26,4 +26,3 @@ def get_db_session() -> Generator[Session, None, None]: yield session finally: session.close() - diff --git a/app/main.py b/app/main.py index e92b0cd..42efe4b 100644 --- a/app/main.py +++ b/app/main.py @@ -4,14 +4,17 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from app import models # noqa: F401 from app.api.routes import pages, status +from app.api.routes.location import router as location_router from app.config import get_settings def ensure_runtime_dirs() -> None: settings = get_settings() - if settings.sqlite_path is not None: - settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True) + for path in (settings.location_sqlite_path, settings.poo_sqlite_path): + if path is not None: + path.parent.mkdir(parents=True, exist_ok=True) @asynccontextmanager @@ -38,8 +41,8 @@ def create_app() -> FastAPI: app.include_router(status.router) app.include_router(pages.router) + app.include_router(location_router) return app app = create_app() - diff --git a/app/models/__init__.py b/app/models/__init__.py index becfbcb..ae09e84 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,2 +1,5 @@ """SQLAlchemy models package.""" +from app.models.location import Location + +__all__ = ["Location"] diff --git a/app/models/location.py b/app/models/location.py new file mode 100644 index 0000000..aca9dd2 --- /dev/null +++ b/app/models/location.py @@ -0,0 +1,15 @@ +from sqlalchemy import Float, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db import Base + + +class Location(Base): + __tablename__ = "location" + + person: Mapped[str] = mapped_column(String, primary_key=True) + datetime: Mapped[str] = mapped_column(String, primary_key=True) + latitude: Mapped[float] = mapped_column(Float, nullable=False) + longitude: Mapped[float] = mapped_column(Float, nullable=False) + altitude: Mapped[float | None] = mapped_column(Float, nullable=True) + diff --git a/app/schemas/location.py b/app/schemas/location.py new file mode 100644 index 0000000..94c41b1 --- /dev/null +++ b/app/schemas/location.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel, ConfigDict + + +class LocationRecordRequest(BaseModel): + person: str + latitude: str + longitude: str + altitude: str = "" + + model_config = ConfigDict(extra="forbid") + diff --git a/app/services/location.py b/app/services/location.py new file mode 100644 index 0000000..6e9deae --- /dev/null +++ b/app/services/location.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone + +from sqlalchemy import insert +from sqlalchemy.orm import Session + +from app.models.location import Location +from app.schemas.location import LocationRecordRequest + + +def _parse_float_compat(value: str) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _utc_now_rfc3339() -> str: + now = datetime.now(timezone.utc).replace(microsecond=0) + return now.isoformat().replace("+00:00", "Z") + + +def record_location(session: Session, payload: LocationRecordRequest) -> None: + stmt = ( + insert(Location) + .prefix_with("OR IGNORE") + .values( + person=payload.person, + datetime=_utc_now_rfc3339(), + latitude=_parse_float_compat(payload.latitude), + longitude=_parse_float_compat(payload.longitude), + altitude=_parse_float_compat(payload.altitude), + ) + ) + session.execute(stmt) + session.commit() + diff --git a/docker-compose.yml b/docker-compose.yml index c8b447a..eceb282 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,9 @@ services: env_file: - .env environment: - DATABASE_URL: sqlite:////app/data/app.db + LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db + POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db APP_HOST: 0.0.0.0 APP_PORT: 8000 volumes: - ./data:/app/data - diff --git a/docs/location-recorder.md b/docs/location-recorder.md new file mode 100644 index 0000000..ceff9bb --- /dev/null +++ b/docs/location-recorder.md @@ -0,0 +1,96 @@ +# Location Recorder + +本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略。 + +## Legacy 事实基线 + +当前 legacy SQLite 中 `location` 表的真实 schema 为: + +```sql +CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) +); +``` + +历史上 legacy Go 实现使用: + +```sql +PRAGMA user_version = 2; +``` + +这代表旧系统曾依赖 `user_version` 管理 location 数据库版本,但这不再是 Python 项目的长期 migration 机制。 + +## 当前策略 + +当前采用的最小必要接管方案是: + +1. 把上述 `location` schema 视为 Alembic baseline +2. 新数据库通过 Alembic `upgrade head` 初始化 +3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,就通过 `alembic stamp` 接管 +4. 未来不再以 `PRAGMA user_version` 作为主 migration 机制 + +当前 baseline revision 是: + +- `20260419_01_location_baseline` + +## 新数据库初始化 + +对于一个全新 SQLite 数据库,执行: + +```bash +alembic upgrade head +``` + +这会创建与 legacy 相同的 `location` 表结构,并在库中建立 Alembic revision 记录。 + +## 旧数据库接管 + +对于已经存在的 legacy SQLite 数据库: + +1. 先确认其 `location` 表 schema 与 baseline 一致 +2. 旧库里的 `PRAGMA user_version = 2` 仅视为历史事实,不再继续沿用 +3. 确认无误后,对该数据库执行 `stamp`,而不是重新跑创建表 migration + +示例: + +```bash +LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 20260419_01_location_baseline +``` + +这样做的含义是: + +- 告诉 Alembic:这个数据库已经处于 baseline 结构 +- 不修改已有 `location` 表数据 +- 后续 migration 由 Alembic 接管 + +## 关于 `data/locationRecorder.db` + +你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于: + +- 人工核对 schema +- 手动验证 `stamp` 接管流程 +- 做开发时的兼容性确认 + +但当前代码不应硬依赖这个文件存在。 + +## 测试样本的安全使用方式 + +如果要用 legacy SQLite 样本做测试或验证,应遵守: + +1. 不直接在原始样本文件上跑测试 +2. 先复制到临时路径 +3. 所有 `stamp`、写入、实验性 migration 都只针对副本执行 + +自动化测试里当前采用的方式是: + +- 构造一个“legacy 风格”的临时 SQLite 文件 +- 建出同样的 `location` 表 +- 设置 `PRAGMA user_version = 2` +- 再执行 Alembic `stamp` + +这样可以验证接管路径,同时不污染真实样本库。 diff --git a/docs/migration-notes.md b/docs/migration-notes.md index 5305177..a81fdcc 100644 --- a/docs/migration-notes.md +++ b/docs/migration-notes.md @@ -11,16 +11,56 @@ - 建立 pytest 基础设施 - 建立 Docker / Compose 基础骨架 - 建立 OpenAPI 导出脚本 +- 迁入 `location recorder` 第一版 + +## 数据库配置现状 + +当前系统在配置层上已明确保留两个独立 SQLite DB 文件: + +- `LOCATION_DATABASE_URL` +- `POO_DATABASE_URL` + +当前阶段不打算把这两个数据库合并。 + +其中: + +- `location` 模块已经实际接到 `LOCATION_DATABASE_URL` +- `poo` 目前只保留 `POO_DATABASE_URL` 配置占位,等待模块迁入 ## 当前阶段未做内容 - 未迁移 TickTick 业务逻辑 - 未迁移 Home Assistant 业务逻辑 - 未迁移 poo records -- 未迁移 location / life trajectory - 未实现真实 OAuth 流程 - 未做数据迁移 +## Location recorder 说明 + +当前 Python 项目已经接入 `POST /location/record`,并对齐 legacy SQLite schema: + +```sql +CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) +); +``` + +当前已经补上最小 Alembic baseline / 接管策略: + +- `location` 当前 schema 被视为 Alembic baseline +- 新数据库通过 `alembic upgrade head` 初始化 +- 已有 legacy SQLite 数据库通过 `alembic stamp` 接管 +- `PRAGMA user_version = 2` 仅保留为历史事实,不再作为新的主 migration 机制 + +详见: + +- [location-recorder.md](location-recorder.md) + ## 后续建议顺序 建议继续沿用既有迁移文档中的顺序: @@ -37,4 +77,3 @@ - 不要把旧 Python 版本当作设计基线 - 不要重新引入 Notion 作为 Python 主系统能力 - 在迁业务模块时,优先补 contract tests - diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..eb60c7b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,15 @@ +from app.config import Settings + + +def test_settings_support_two_independent_database_urls(monkeypatch) -> None: + monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") + monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") + + settings = Settings() + + assert settings.location_database_url == "sqlite:///./data/locationRecorder.db" + assert settings.poo_database_url == "sqlite:///./data/pooRecorder.db" + assert settings.location_sqlite_path is not None + assert settings.location_sqlite_path.name == "locationRecorder.db" + assert settings.poo_sqlite_path is not None + assert settings.poo_sqlite_path.name == "pooRecorder.db" diff --git a/tests/test_location.py b/tests/test_location.py new file mode 100644 index 0000000..45a4241 --- /dev/null +++ b/tests/test_location.py @@ -0,0 +1,172 @@ +from datetime import datetime +from pathlib import Path +import sqlite3 + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +import app.db +from app.main import create_app + +LOCATION_BASELINE_REVISION = "20260419_01_location_baseline" + + +def _make_alembic_config(database_url: str) -> Config: + config = Config("alembic.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + +@pytest.fixture +def location_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + database_path = tmp_path / "location_test.db" + database_url = f"sqlite:///{database_path}" + + command.upgrade(_make_alembic_config(database_url), "head") + + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + monkeypatch.setattr(app.db, "engine", engine) + monkeypatch.setattr(app.db, "SessionLocal", session_local) + + from fastapi.testclient import TestClient + + fastapi_app = create_app() + with TestClient(fastapi_app) as client: + yield client, engine + + engine.dispose() + + +def test_location_record_endpoint_writes_row(location_client) -> None: + client, engine = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "1.23", + "longitude": "4.56", + "altitude": "7.89", + }, + ) + + assert response.status_code == 200 + assert response.text == "" + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT person, datetime, latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.person == "tianyu" + assert row.latitude == pytest.approx(1.23) + assert row.longitude == pytest.approx(4.56) + assert row.altitude == pytest.approx(7.89) + datetime.fromisoformat(row.datetime.replace("Z", "+00:00")) + + +def test_location_record_endpoint_rejects_unknown_fields(location_client) -> None: + client, _ = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "1.23", + "longitude": "4.56", + "extra": "not-allowed", + }, + ) + + assert response.status_code == 400 + + +def test_location_record_endpoint_keeps_legacy_lenient_number_parsing(location_client) -> None: + client, engine = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "bad-lat", + "longitude": "bad-long", + "altitude": "bad-alt", + }, + ) + + assert response.status_code == 200 + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.latitude == pytest.approx(0.0) + assert row.longitude == pytest.approx(0.0) + assert row.altitude == pytest.approx(0.0) + + +def test_legacy_style_location_db_can_be_stamped_and_adopted( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + database_path = tmp_path / "legacy_location.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) + ) + """ + ) + conn.execute("PRAGMA user_version = 2") + conn.commit() + conn.close() + + database_url = f"sqlite:///{database_path}" + command.stamp(_make_alembic_config(database_url), LOCATION_BASELINE_REVISION) + + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) + monkeypatch.setattr(app.db, "engine", engine) + monkeypatch.setattr(app.db, "SessionLocal", session_local) + + from fastapi.testclient import TestClient + + fastapi_app = create_app() + with TestClient(fastapi_app) as client: + response = client.post( + "/location/record", + json={ + "person": "legacy-user", + "latitude": "12.3", + "longitude": "45.6", + "altitude": "7.8", + }, + ) + + assert response.status_code == 200 + + with engine.connect() as db_conn: + revision = db_conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() + row_count = db_conn.execute(text("SELECT COUNT(*) FROM location")).scalar_one() + + assert revision == LOCATION_BASELINE_REVISION + assert row_count == 1 + + engine.dispose() From 8aeb0723c1b91eb7c39b2753a78419931ec5f211 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 21:57:31 +0200 Subject: [PATCH 03/17] Add location db adoption runbook --- docs/location-recorder.md | 70 +++++++++++++++++++-- docs/migration-notes.md | 7 +++ scripts/__init__.py | 1 + scripts/location_db_adopt.py | 118 +++++++++++++++++++++++++++++++++++ tests/test_location.py | 105 ++++++++++++++++++++++++++++++- 5 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 scripts/__init__.py create mode 100644 scripts/location_db_adopt.py diff --git a/docs/location-recorder.md b/docs/location-recorder.md index ceff9bb..bb77ef2 100644 --- a/docs/location-recorder.md +++ b/docs/location-recorder.md @@ -1,6 +1,6 @@ # Location Recorder -本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略。 +本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略,以及 legacy SQLite 接管 runbook。 ## Legacy 事实基线 @@ -38,9 +38,33 @@ PRAGMA user_version = 2; - `20260419_01_location_baseline` +当前提供的最小脚本入口是: + +```bash +python scripts/location_db_adopt.py +``` + +如果你更喜欢模块方式运行,也可以用: + +```bash +python -m scripts.location_db_adopt +``` + +它只针对 `LOCATION_DATABASE_URL` 工作,并且遵守保守接管原则: + +- 本地已有 DB 文件:先校验,再接管 +- 本地没有 DB 文件:按新库初始化 +- 任一校验不通过:立即报错并停止 + ## 新数据库初始化 -对于一个全新 SQLite 数据库,执行: +如果本地不存在 `LOCATION_DATABASE_URL` 指向的 DB 文件: + +- 脚本会先创建父目录 +- 然后执行 Alembic `upgrade head` +- 最终建立 `location` 表与 `alembic_version` 表 + +手工执行时也等价于: ```bash alembic upgrade head @@ -52,9 +76,12 @@ alembic upgrade head 对于已经存在的 legacy SQLite 数据库: -1. 先确认其 `location` 表 schema 与 baseline 一致 -2. 旧库里的 `PRAGMA user_version = 2` 仅视为历史事实,不再继续沿用 -3. 确认无误后,对该数据库执行 `stamp`,而不是重新跑创建表 migration +1. 先确认 DB 文件存在 +2. 读取当前 DB 中 `location` 表的实际 schema +3. 与 baseline schema 做严格比对 +4. 再检查 `PRAGMA user_version` +5. 只有 schema 匹配且 `user_version = 2` 时,才执行 Alembic `stamp` +6. 接管完成后,后续 migration 才交给 Alembic 管理 示例: @@ -62,12 +89,37 @@ alembic upgrade head LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 20260419_01_location_baseline ``` +或直接执行脚本: + +```bash +LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db python scripts/location_db_adopt.py +``` + 这样做的含义是: - 告诉 Alembic:这个数据库已经处于 baseline 结构 - 不修改已有 `location` 表数据 - 后续 migration 由 Alembic 接管 +## Fail Closed 原则 + +当前策略是保守接管,不做未知 legacy 状态的自动修复。 + +如果出现以下任一情况,脚本会直接报错并停止: + +- 找不到 `location` 表 +- `location` 表 schema 与 baseline 不一致 +- `PRAGMA user_version` 不等于 `2` +- 目标 DB 不是 SQLite URL + +当前不会尝试: + +- 自动修表 +- 自动调整 `user_version` +- 自动推断未知 legacy 状态 + +如果发生这些情况,应先人工确认数据库状态,再决定是否需要单独迁移或修复。 + ## 关于 `data/locationRecorder.db` 你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于: @@ -91,6 +143,12 @@ LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 2026041 - 构造一个“legacy 风格”的临时 SQLite 文件 - 建出同样的 `location` 表 - 设置 `PRAGMA user_version = 2` -- 再执行 Alembic `stamp` +- 再执行接管脚本中的 adopt 逻辑 + +同时也覆盖: + +- DB 文件不存在时的新库初始化路径 +- schema 不匹配时的失败路径 +- `user_version` 不匹配时的失败路径 这样可以验证接管路径,同时不污染真实样本库。 diff --git a/docs/migration-notes.md b/docs/migration-notes.md index a81fdcc..54079e4 100644 --- a/docs/migration-notes.md +++ b/docs/migration-notes.md @@ -61,6 +61,13 @@ CREATE TABLE location ( - [location-recorder.md](location-recorder.md) +当前还额外提供了一个最小 runbook / script 组合,用于保守接管 legacy location DB: + +- 先严格校验 schema +- 再严格校验 `PRAGMA user_version = 2` +- 只有全部匹配才执行 Alembic `stamp` +- 不匹配则直接失败,不自动修复 + ## 后续建议顺序 建议继续沿用既有迁移文档中的顺序: diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..02702eb --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Project helper scripts.""" diff --git a/scripts/location_db_adopt.py b/scripts/location_db_adopt.py new file mode 100644 index 0000000..bdedf3a --- /dev/null +++ b/scripts/location_db_adopt.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +from alembic import command +from alembic.config import Config + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.config import get_settings + +LOCATION_BASELINE_REVISION = "20260419_01_location_baseline" +EXPECTED_USER_VERSION = 2 +EXPECTED_LOCATION_TABLE_INFO = [ + (0, "person", "TEXT", 1, None, 1), + (1, "datetime", "TEXT", 1, None, 2), + (2, "latitude", "REAL", 1, None, 0), + (3, "longitude", "REAL", 1, None, 0), + (4, "altitude", "REAL", 0, None, 0), +] + + +class LocationDatabaseAdoptionError(RuntimeError): + """Raised when a legacy location database does not match the expected baseline.""" + + +def _database_path_from_url(database_url: str) -> Path: + prefix = "sqlite:///" + if not database_url.startswith(prefix): + raise LocationDatabaseAdoptionError( + f"Only sqlite URLs are supported for location DB adoption, got: {database_url}" + ) + return Path(database_url[len(prefix) :]) + + +def _make_alembic_config(database_url: str) -> Config: + config = Config("alembic.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + +def _location_table_exists(database_path: Path) -> bool: + conn = sqlite3.connect(database_path) + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'location'" + ).fetchone() + return row is not None + finally: + conn.close() + + +def _fetch_location_table_info(database_path: Path) -> list[tuple]: + conn = sqlite3.connect(database_path) + try: + return list(conn.execute("PRAGMA table_info(location)")) + finally: + conn.close() + + +def _fetch_user_version(database_path: Path) -> int: + conn = sqlite3.connect(database_path) + try: + return conn.execute("PRAGMA user_version").fetchone()[0] + finally: + conn.close() + + +def validate_legacy_location_db(database_url: str) -> None: + database_path = _database_path_from_url(database_url) + if not database_path.exists(): + raise LocationDatabaseAdoptionError(f"Location DB file does not exist: {database_path}") + + if not _location_table_exists(database_path): + raise LocationDatabaseAdoptionError("Expected table 'location' was not found in the DB") + + table_info = _fetch_location_table_info(database_path) + if table_info != EXPECTED_LOCATION_TABLE_INFO: + raise LocationDatabaseAdoptionError( + "Location table schema does not match the expected baseline schema" + ) + + user_version = _fetch_user_version(database_path) + if user_version != EXPECTED_USER_VERSION: + raise LocationDatabaseAdoptionError( + f"Expected PRAGMA user_version = {EXPECTED_USER_VERSION}, got {user_version}" + ) + + +def adopt_or_initialize_location_db(database_url: str) -> str: + database_path = _database_path_from_url(database_url) + alembic_config = _make_alembic_config(database_url) + + if database_path.exists(): + validate_legacy_location_db(database_url) + command.stamp(alembic_config, LOCATION_BASELINE_REVISION) + return "adopted" + + database_path.parent.mkdir(parents=True, exist_ok=True) + command.upgrade(alembic_config, "head") + return "initialized" + + +def main() -> None: + settings = get_settings() + result = adopt_or_initialize_location_db(settings.location_database_url) + if result == "initialized": + print("Initialized a new location DB via Alembic upgrade head.") + else: + print("Validated legacy location DB and stamped Alembic baseline successfully.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_location.py b/tests/test_location.py index 45a4241..c746254 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -10,8 +10,12 @@ from sqlalchemy.orm import sessionmaker import app.db from app.main import create_app - -LOCATION_BASELINE_REVISION = "20260419_01_location_baseline" +from scripts.location_db_adopt import ( + EXPECTED_USER_VERSION, + LOCATION_BASELINE_REVISION, + LocationDatabaseAdoptionError, + adopt_or_initialize_location_db, +) def _make_alembic_config(database_url: str) -> Config: @@ -170,3 +174,100 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( assert row_count == 1 engine.dispose() + + +def test_location_db_adoption_initializes_new_db(tmp_path: Path) -> None: + database_path = tmp_path / "new_location.db" + result = adopt_or_initialize_location_db(f"sqlite:///{database_path}") + + assert result == "initialized" + assert database_path.exists() + + conn = sqlite3.connect(database_path) + try: + revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0] + location_table = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'location'" + ).fetchone() + finally: + conn.close() + + assert revision == LOCATION_BASELINE_REVISION + assert location_table is not None + + +def test_location_db_adoption_validates_and_stamps_legacy_db(tmp_path: Path) -> None: + database_path = tmp_path / "legacy_location.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) + ) + """ + ) + conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}") + conn.commit() + conn.close() + + result = adopt_or_initialize_location_db(f"sqlite:///{database_path}") + + assert result == "adopted" + + conn = sqlite3.connect(database_path) + try: + revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0] + finally: + conn.close() + + assert revision == LOCATION_BASELINE_REVISION + + +def test_location_db_adoption_fails_closed_on_schema_mismatch(tmp_path: Path) -> None: + database_path = tmp_path / "bad_schema.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + PRIMARY KEY (person, datetime) + ) + """ + ) + conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}") + conn.commit() + conn.close() + + with pytest.raises(LocationDatabaseAdoptionError, match="schema does not match"): + adopt_or_initialize_location_db(f"sqlite:///{database_path}") + + +def test_location_db_adoption_fails_closed_on_user_version_mismatch(tmp_path: Path) -> None: + database_path = tmp_path / "bad_user_version.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) + ) + """ + ) + conn.execute("PRAGMA user_version = 999") + conn.commit() + conn.close() + + with pytest.raises(LocationDatabaseAdoptionError, match="Expected PRAGMA user_version"): + adopt_or_initialize_location_db(f"sqlite:///{database_path}") From 1a2f9c75d916de9e2f9687dd104f47c15fa15c0e Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 23:02:43 +0200 Subject: [PATCH 04/17] Harden location db startup validation --- app/main.py | 13 ++++++ docs/location-recorder.md | 29 +++++++++---- docs/migration-notes.md | 6 +++ scripts/location_db_adopt.py | 59 ++++++++++++++++++++++++++ tests/conftest.py | 43 ++++++++++++++++++- tests/test_app.py | 81 ++++++++++++++++++++++++++++++++++++ tests/test_location.py | 52 +++++++++++++++++++---- 7 files changed, 266 insertions(+), 17 deletions(-) diff --git a/app/main.py b/app/main.py index 42efe4b..e1c3ee5 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,18 @@ from app import models # noqa: F401 from app.api.routes import pages, status from app.api.routes.location import router as location_router from app.config import get_settings +from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db + + +def ensure_location_db_ready() -> None: + settings = get_settings() + if settings.location_sqlite_path is None: + return + + try: + validate_location_runtime_db(settings.location_database_url) + except LocationDatabaseAdoptionError as exc: + raise RuntimeError(str(exc)) from exc def ensure_runtime_dirs() -> None: @@ -20,6 +32,7 @@ def ensure_runtime_dirs() -> None: @asynccontextmanager async def lifespan(_: FastAPI): ensure_runtime_dirs() + ensure_location_db_ready() yield diff --git a/docs/location-recorder.md b/docs/location-recorder.md index bb77ef2..6ed6d9b 100644 --- a/docs/location-recorder.md +++ b/docs/location-recorder.md @@ -31,8 +31,10 @@ PRAGMA user_version = 2; 1. 把上述 `location` schema 视为 Alembic baseline 2. 新数据库通过 Alembic `upgrade head` 初始化 -3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,就通过 `alembic stamp` 接管 -4. 未来不再以 `PRAGMA user_version` 作为主 migration 机制 +3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,再通过 `alembic stamp` 接管 +4. 如果数据库已经存在 `alembic_version`,则必须先确认当前 revision 与项目预期 baseline 一致 +5. 只有 revision 一致时,才视为该库已经被正确接管 +6. 未来不再以 `PRAGMA user_version` 作为主 migration 机制 当前 baseline revision 是: @@ -56,6 +58,15 @@ python -m scripts.location_db_adopt - 本地没有 DB 文件:按新库初始化 - 任一校验不通过:立即报错并停止 +应用本身在启动时不会自动替你初始化 `location` 数据库。 +应用启动时会对 `LOCATION_DATABASE_URL` 做只读校验: + +- 文件不存在:直接报错,并提示先运行接管脚本 +- 文件存在但还没有 `alembic_version`:直接报错,要求先完成 legacy 接管 +- 文件已被 Alembic 管理但 revision 不匹配:直接报错并拒绝启动 + +这是有意为之,用来避免应用在错误路径上静默创建新库,或带着错误数据库版本继续跑业务。 + ## 新数据库初始化 如果本地不存在 `LOCATION_DATABASE_URL` 指向的 DB 文件: @@ -77,11 +88,14 @@ alembic upgrade head 对于已经存在的 legacy SQLite 数据库: 1. 先确认 DB 文件存在 -2. 读取当前 DB 中 `location` 表的实际 schema -3. 与 baseline schema 做严格比对 -4. 再检查 `PRAGMA user_version` -5. 只有 schema 匹配且 `user_version = 2` 时,才执行 Alembic `stamp` -6. 接管完成后,后续 migration 才交给 Alembic 管理 +2. 如果已经存在 `alembic_version` 表,则先读取当前 revision +3. 如果 revision 等于 `20260419_01_location_baseline`,则视为该库已经被 Alembic 正确接管 +4. 如果 revision 不匹配,立即报错并停止,不做任何自动修复 +5. 如果还没有 `alembic_version` 表,则读取当前 DB 中 `location` 表的实际 schema +6. 与 baseline schema 做严格比对 +7. 再检查 `PRAGMA user_version` +8. 只有 schema 匹配且 `user_version = 2` 时,才执行 Alembic `stamp` +9. 接管完成后,后续 migration 才交给 Alembic 管理 示例: @@ -110,6 +124,7 @@ LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db python scripts/locati - 找不到 `location` 表 - `location` 表 schema 与 baseline 不一致 - `PRAGMA user_version` 不等于 `2` +- 已有 `alembic_version`,但 revision 与预期 baseline 不一致 - 目标 DB 不是 SQLite URL 当前不会尝试: diff --git a/docs/migration-notes.md b/docs/migration-notes.md index 54079e4..f0d5a3b 100644 --- a/docs/migration-notes.md +++ b/docs/migration-notes.md @@ -68,6 +68,12 @@ CREATE TABLE location ( - 只有全部匹配才执行 Alembic `stamp` - 不匹配则直接失败,不自动修复 +同时,应用启动阶段现在也会对 location DB 做保守的只读校验: + +- DB 文件不存在时拒绝启动 +- DB 尚未被 Alembic 接管时拒绝启动 +- DB revision 与当前应用预期不一致时拒绝启动 + ## 后续建议顺序 建议继续沿用既有迁移文档中的顺序: diff --git a/scripts/location_db_adopt.py b/scripts/location_db_adopt.py index bdedf3a..5e3f782 100644 --- a/scripts/location_db_adopt.py +++ b/scripts/location_db_adopt.py @@ -54,6 +54,30 @@ def _location_table_exists(database_path: Path) -> bool: conn.close() +def _alembic_version_table_exists(database_path: Path) -> bool: + conn = sqlite3.connect(database_path) + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'" + ).fetchone() + return row is not None + finally: + conn.close() + + +def _fetch_alembic_revision(database_path: Path) -> str: + conn = sqlite3.connect(database_path) + try: + row = conn.execute("SELECT version_num FROM alembic_version").fetchone() + if row is None: + raise LocationDatabaseAdoptionError( + "Alembic version table exists but contains no revision" + ) + return row[0] + finally: + conn.close() + + def _fetch_location_table_info(database_path: Path) -> list[tuple]: conn = sqlite3.connect(database_path) try: @@ -91,11 +115,44 @@ def validate_legacy_location_db(database_url: str) -> None: ) +def validate_location_runtime_db(database_url: str) -> None: + database_path = _database_path_from_url(database_url) + if not database_path.exists(): + raise LocationDatabaseAdoptionError( + "Location DB file was not found. Run 'python scripts/location_db_adopt.py' " + "first to initialize or adopt the location DB before starting the app." + ) + + if not _alembic_version_table_exists(database_path): + raise LocationDatabaseAdoptionError( + "Location DB exists but is not yet Alembic-managed. Run " + "'python scripts/location_db_adopt.py' first to adopt the legacy DB " + "before starting the app." + ) + + current_revision = _fetch_alembic_revision(database_path) + if current_revision != LOCATION_BASELINE_REVISION: + raise LocationDatabaseAdoptionError( + "Location DB revision mismatch. Refusing to start the app: " + f"expected {LOCATION_BASELINE_REVISION}, got {current_revision}" + ) + + def adopt_or_initialize_location_db(database_url: str) -> str: database_path = _database_path_from_url(database_url) alembic_config = _make_alembic_config(database_url) if database_path.exists(): + if _alembic_version_table_exists(database_path): + current_revision = _fetch_alembic_revision(database_path) + if current_revision != LOCATION_BASELINE_REVISION: + raise LocationDatabaseAdoptionError( + "Location DB is already Alembic-managed but revision does not match " + f"the expected baseline: expected {LOCATION_BASELINE_REVISION}, " + f"got {current_revision}" + ) + return "already_managed" + validate_legacy_location_db(database_url) command.stamp(alembic_config, LOCATION_BASELINE_REVISION) return "adopted" @@ -110,6 +167,8 @@ def main() -> None: result = adopt_or_initialize_location_db(settings.location_database_url) if result == "initialized": print("Initialized a new location DB via Alembic upgrade head.") + elif result == "already_managed": + print("Location DB is already Alembic-managed at the expected baseline revision.") else: print("Validated legacy location DB and stamped Alembic baseline successfully.") diff --git a/tests/conftest.py b/tests/conftest.py index 4f8484c..6df20d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,51 @@ +from pathlib import Path + import pytest +from alembic import command +from alembic.config import Config from fastapi.testclient import TestClient +from app.config import get_settings from app.main import create_app +def _make_alembic_config(database_url: str) -> Config: + config = Config("alembic.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + @pytest.fixture -def app(): - return create_app() +def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + location_database_path = tmp_path / "location_test.db" + poo_database_path = tmp_path / "poo_placeholder.db" + location_database_url = f"sqlite:///{location_database_path}" + poo_database_url = f"sqlite:///{poo_database_path}" + + monkeypatch.setenv("LOCATION_DATABASE_URL", location_database_url) + monkeypatch.setenv("POO_DATABASE_URL", poo_database_url) + get_settings.cache_clear() + + try: + yield { + "location_path": location_database_path, + "location_url": location_database_url, + "poo_path": poo_database_path, + "poo_url": poo_database_url, + } + finally: + get_settings.cache_clear() + + +@pytest.fixture +def ready_location_database(test_database_urls): + command.upgrade(_make_alembic_config(test_database_urls["location_url"]), "head") + return test_database_urls + + +@pytest.fixture +def app(ready_location_database): + yield create_app() @pytest.fixture diff --git a/tests/test_app.py b/tests/test_app.py index b6140f2..628acfd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,19 @@ +import sqlite3 + +import anyio +import pytest +from alembic import command from fastapi.testclient import TestClient +from app.config import get_settings +from app.main import create_app +from tests.conftest import _make_alembic_config + + +async def _run_lifespan(app) -> None: + async with app.router.lifespan_context(app): + return None + def test_app_starts(client: TestClient) -> None: response = client.get("/") @@ -11,3 +25,70 @@ def test_status_endpoint(client: TestClient) -> None: assert response.status_code == 200 assert response.json() == {"status": "ok"} + +def test_app_start_fails_when_location_db_missing( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{tmp_path / 'missing.db'}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}") + get_settings.cache_clear() + + app = create_app() + with pytest.raises(RuntimeError, match="Run 'python scripts/location_db_adopt.py' first"): + anyio.run(_run_lifespan, app) + + get_settings.cache_clear() + + +def test_app_start_fails_when_location_db_exists_but_is_not_adopted( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + database_path = tmp_path / "legacy_only.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) + ) + """ + ) + conn.execute("PRAGMA user_version = 2") + conn.commit() + conn.close() + + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}") + get_settings.cache_clear() + + app = create_app() + with pytest.raises(RuntimeError, match="is not yet Alembic-managed"): + anyio.run(_run_lifespan, app) + + get_settings.cache_clear() + + +def test_app_start_fails_when_location_db_revision_mismatches( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + database_path = tmp_path / "wrong_revision.db" + command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head") + + conn = sqlite3.connect(database_path) + conn.execute("UPDATE alembic_version SET version_num = 'wrong_revision'") + conn.commit() + conn.close() + + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}") + get_settings.cache_clear() + + app = create_app() + with pytest.raises(RuntimeError, match="Location DB revision mismatch"): + anyio.run(_run_lifespan, app) + + get_settings.cache_clear() diff --git a/tests/test_location.py b/tests/test_location.py index c746254..522e0d6 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -25,11 +25,8 @@ def _make_alembic_config(database_url: str) -> Config: @pytest.fixture -def location_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): - database_path = tmp_path / "location_test.db" - database_url = f"sqlite:///{database_path}" - - command.upgrade(_make_alembic_config(database_url), "head") +def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): + database_url = ready_location_database["location_url"] engine = create_engine(database_url, connect_args={"check_same_thread": False}) session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) @@ -122,9 +119,11 @@ def test_location_record_endpoint_keeps_legacy_lenient_number_parsing(location_c def test_legacy_style_location_db_can_be_stamped_and_adopted( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + test_database_urls, monkeypatch: pytest.MonkeyPatch ) -> None: - database_path = tmp_path / "legacy_location.db" + database_path = test_database_urls["location_path"] + database_url = test_database_urls["location_url"] + conn = sqlite3.connect(database_path) conn.execute( """ @@ -142,7 +141,6 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( conn.commit() conn.close() - database_url = f"sqlite:///{database_path}" command.stamp(_make_alembic_config(database_url), LOCATION_BASELINE_REVISION) engine = create_engine(database_url, connect_args={"check_same_thread": False}) @@ -228,6 +226,44 @@ def test_location_db_adoption_validates_and_stamps_legacy_db(tmp_path: Path) -> assert revision == LOCATION_BASELINE_REVISION +def test_location_db_adoption_accepts_already_managed_matching_revision( + tmp_path: Path, +) -> None: + database_path = tmp_path / "managed_location.db" + command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head") + + result = adopt_or_initialize_location_db(f"sqlite:///{database_path}") + + assert result == "already_managed" + + +def test_location_db_adoption_fails_closed_on_alembic_revision_mismatch( + tmp_path: Path, +) -> None: + database_path = tmp_path / "wrong_revision.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE location ( + person TEXT NOT NULL, + datetime TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + altitude REAL, + PRIMARY KEY (person, datetime) + ) + """ + ) + conn.execute("CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL)") + conn.execute("INSERT INTO alembic_version (version_num) VALUES ('wrong_revision')") + conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}") + conn.commit() + conn.close() + + with pytest.raises(LocationDatabaseAdoptionError, match="revision does not match"): + adopt_or_initialize_location_db(f"sqlite:///{database_path}") + + def test_location_db_adoption_fails_closed_on_schema_mismatch(tmp_path: Path) -> None: database_path = tmp_path / "bad_schema.db" conn = sqlite3.connect(database_path) From d0dc8e893a23e2861973a2a1a6b3359e1a67a4cc Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 23:18:20 +0200 Subject: [PATCH 05/17] Tighten location request validation --- app/api/routes/location.py | 15 ++++-- app/schemas/location.py | 3 +- app/services/location.py | 16 ++++-- docs/location-recorder.md | 7 +++ tests/test_location.py | 106 +++++++++++++++++++++++++++++++++++-- 5 files changed, 132 insertions(+), 15 deletions(-) diff --git a/app/api/routes/location.py b/app/api/routes/location.py index 33132be..6c03bf4 100644 --- a/app/api/routes/location.py +++ b/app/api/routes/location.py @@ -1,4 +1,5 @@ import json +import logging from fastapi import APIRouter, Depends, Request from fastapi.responses import PlainTextResponse, Response @@ -10,6 +11,8 @@ from app.schemas.location import LocationRecordRequest from app.services.location import record_location router = APIRouter(tags=["location"]) +logger = logging.getLogger(__name__) +BAD_REQUEST_MESSAGE = "bad request" @router.post("/location/record") @@ -18,11 +21,15 @@ async def create_location_record(request: Request, db: Session = Depends(get_db) raw_payload = await request.body() data = json.loads(raw_payload) payload = LocationRecordRequest.model_validate(data) + record_location(db, payload) except json.JSONDecodeError as exc: - return PlainTextResponse(str(exc), status_code=400) + logger.warning("Rejected location request due to invalid JSON: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) except ValidationError as exc: - return PlainTextResponse(str(exc), status_code=400) + logger.warning("Rejected location request due to payload validation failure: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + except ValueError as exc: + logger.warning("Rejected location request due to invalid numeric input: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) - record_location(db, payload) return Response(status_code=200) - diff --git a/app/schemas/location.py b/app/schemas/location.py index 94c41b1..9a7f670 100644 --- a/app/schemas/location.py +++ b/app/schemas/location.py @@ -5,7 +5,6 @@ class LocationRecordRequest(BaseModel): person: str latitude: str longitude: str - altitude: str = "" + altitude: str | None = None model_config = ConfigDict(extra="forbid") - diff --git a/app/services/location.py b/app/services/location.py index 6e9deae..b9b5618 100644 --- a/app/services/location.py +++ b/app/services/location.py @@ -7,13 +7,20 @@ from app.models.location import Location from app.schemas.location import LocationRecordRequest -def _parse_float_compat(value: str) -> float: +def _parse_optional_float_compat(value: str | None) -> float: try: return float(value) except (TypeError, ValueError): return 0.0 +def _parse_required_float(value: str, field_name: str) -> float: + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid numeric value for {field_name}") from exc + + def _utc_now_rfc3339() -> str: now = datetime.now(timezone.utc).replace(microsecond=0) return now.isoformat().replace("+00:00", "Z") @@ -26,11 +33,10 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None: .values( person=payload.person, datetime=_utc_now_rfc3339(), - latitude=_parse_float_compat(payload.latitude), - longitude=_parse_float_compat(payload.longitude), - altitude=_parse_float_compat(payload.altitude), + latitude=_parse_required_float(payload.latitude, "latitude"), + longitude=_parse_required_float(payload.longitude, "longitude"), + altitude=_parse_optional_float_compat(payload.altitude), ) ) session.execute(stmt) session.commit() - diff --git a/docs/location-recorder.md b/docs/location-recorder.md index 6ed6d9b..f532ec3 100644 --- a/docs/location-recorder.md +++ b/docs/location-recorder.md @@ -2,6 +2,13 @@ 本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略,以及 legacy SQLite 接管 runbook。 +当前 Python 版本的 `POST /location/record` 请求校验策略是: + +- `latitude` 和 `longitude` 为必填,缺失或无法解析成合法数值时返回 `400 bad request` +- `altitude` 为可选,缺失或非法时按 `0` 处理 +- unknown field 仍返回 `400 bad request` +- 对 caller 的错误响应保持简洁,不直接暴露底层校验细节;详细原因只写日志 + ## Legacy 事实基线 当前 legacy SQLite 中 `location` 表的真实 schema 为: diff --git a/tests/test_location.py b/tests/test_location.py index 522e0d6..47dee9f 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -88,17 +88,115 @@ def test_location_record_endpoint_rejects_unknown_fields(location_client) -> Non ) assert response.status_code == 400 + assert response.text == "bad request" + assert "extra" not in response.text + assert "ValidationError" not in response.text -def test_location_record_endpoint_keeps_legacy_lenient_number_parsing(location_client) -> None: - client, engine = location_client +def test_location_record_endpoint_rejects_missing_latitude(location_client) -> None: + client, _ = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "longitude": "4.56", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "latitude" not in response.text + + +def test_location_record_endpoint_rejects_missing_longitude(location_client) -> None: + client, _ = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "1.23", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "longitude" not in response.text + + +def test_location_record_endpoint_rejects_invalid_latitude(location_client) -> None: + client, _ = location_client response = client.post( "/location/record", json={ "person": "tianyu", "latitude": "bad-lat", + "longitude": "4.56", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "bad-lat" not in response.text + assert "latitude" not in response.text + + +def test_location_record_endpoint_rejects_invalid_longitude(location_client) -> None: + client, _ = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "1.23", "longitude": "bad-long", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "bad-long" not in response.text + assert "longitude" not in response.text + + +def test_location_record_endpoint_defaults_missing_altitude_to_zero(location_client) -> None: + client, engine = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "1.23", + "longitude": "4.56", + }, + ) + + assert response.status_code == 200 + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.latitude == pytest.approx(1.23) + assert row.longitude == pytest.approx(4.56) + assert row.altitude == pytest.approx(0.0) + + +def test_location_record_endpoint_defaults_invalid_altitude_to_zero(location_client) -> None: + client, engine = location_client + + response = client.post( + "/location/record", + json={ + "person": "tianyu", + "latitude": "1.23", + "longitude": "4.56", "altitude": "bad-alt", }, ) @@ -113,8 +211,8 @@ def test_location_record_endpoint_keeps_legacy_lenient_number_parsing(location_c ) ).one() - assert row.latitude == pytest.approx(0.0) - assert row.longitude == pytest.approx(0.0) + assert row.latitude == pytest.approx(1.23) + assert row.longitude == pytest.approx(4.56) assert row.altitude == pytest.approx(0.0) From eb487ccb46437dc6de38fa9aca6ebd5a40f4a665 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Sun, 19 Apr 2026 23:25:13 +0200 Subject: [PATCH 06/17] Track exported OpenAPI schema --- .gitignore | 1 - README.md | 2 + openapi/openapi.json | 88 +++++++++++++++++++++++++++++++++++++++ openapi/openapi.yaml | 56 +++++++++++++++++++++++++ scripts/export_openapi.py | 11 +++-- 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 openapi/openapi.json create mode 100644 openapi/openapi.yaml diff --git a/.gitignore b/.gitignore index 517a4a5..d69d3bb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,3 @@ __pycache__/ *.pyc data/ -openapi/ diff --git a/README.md b/README.md index ffaed7f..9079de9 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ python scripts/export_openapi.py - `openapi/openapi.json` - `openapi/openapi.yaml` +`openapi/` 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。 + ## 容器启动 1. 准备环境变量文件 diff --git a/openapi/openapi.json b/openapi/openapi.json new file mode 100644 index 0000000..8f02aa2 --- /dev/null +++ b/openapi/openapi.json @@ -0,0 +1,88 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Home Automation Backend (Python)", + "description": "Python rewrite skeleton for the home automation backend. This stage provides only the foundation for future module migration.", + "version": "0.1.0" + }, + "paths": { + "/status": { + "get": { + "tags": [ + "system" + ], + "summary": "Get Status", + "operationId": "get_status_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusResponse" + } + } + } + } + } + } + }, + "/": { + "get": { + "tags": [ + "pages" + ], + "summary": "Home", + "operationId": "home__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/location/record": { + "post": { + "tags": [ + "location" + ], + "summary": "Create Location Record", + "operationId": "create_location_record_location_record_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + } + }, + "components": { + "schemas": { + "StatusResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "StatusResponse" + } + } + } +} \ No newline at end of file diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml new file mode 100644 index 0000000..f0db5ad --- /dev/null +++ b/openapi/openapi.yaml @@ -0,0 +1,56 @@ +openapi: 3.1.0 +info: + title: Home Automation Backend (Python) + description: Python rewrite skeleton for the home automation backend. This stage + provides only the foundation for future module migration. + version: 0.1.0 +paths: + /status: + get: + tags: + - system + summary: Get Status + operationId: get_status_status_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + /: + get: + tags: + - pages + summary: Home + operationId: home__get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + /location/record: + post: + tags: + - location + summary: Create Location Record + operationId: create_location_record_location_record_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} +components: + schemas: + StatusResponse: + properties: + status: + type: string + title: Status + type: object + required: + - status + title: StatusResponse diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py index 1adb17a..5917e64 100644 --- a/scripts/export_openapi.py +++ b/scripts/export_openapi.py @@ -1,13 +1,19 @@ +import json +import sys from pathlib import Path import yaml +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + from app.main import create_app def main() -> None: app = create_app() - output_dir = Path("openapi") + output_dir = PROJECT_ROOT / "openapi" output_dir.mkdir(parents=True, exist_ok=True) schema = app.openapi() @@ -15,7 +21,7 @@ def main() -> None: json_path = output_dir / "openapi.json" yaml_path = output_dir / "openapi.yaml" - json_path.write_text(__import__("json").dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8") + json_path.write_text(json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8") yaml_path.write_text(yaml.safe_dump(schema, allow_unicode=True, sort_keys=False), encoding="utf-8") print(f"Wrote {json_path}") @@ -24,4 +30,3 @@ def main() -> None: if __name__ == "__main__": main() - From 151ad4627556a219a6c39ff457c1dd0641008242 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 10:11:02 +0200 Subject: [PATCH 07/17] Add Home Assistant outbound adapter --- .env.example | 1 + README.md | 3 +- app/config.py | 1 + app/integrations/homeassistant.py | 98 +++++++++++++++++++++++++- docs/architecture-overview.md | 4 +- docs/homeassistant-outbound.md | 51 ++++++++++++++ tests/test_config.py | 6 ++ tests/test_homeassistant.py | 113 ++++++++++++++++++++++++++++++ 8 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 docs/homeassistant-outbound.md create mode 100644 tests/test_homeassistant.py diff --git a/.env.example b/.env.example index 4abd352..738bb77 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,5 @@ TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback TICKTICK_TOKEN= HOME_ASSISTANT_BASE_URL=http://localhost:8123 HOME_ASSISTANT_AUTH_TOKEN= +HOME_ASSISTANT_TIMEOUT_SECONDS=1.0 HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= diff --git a/README.md b/README.md index 9079de9..328fe20 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - SQLite + SQLAlchemy + Alembic 基础设施 - 极简 server-side templates - location recorder 第一版迁移 +- Home Assistant outbound integration layer - pytest 测试基础 - OpenAPI 导出脚本 - Docker / Compose 基础骨架 @@ -18,7 +19,7 @@ 当前阶段明确不包含: - TickTick 业务逻辑迁移 -- Home Assistant 业务逻辑迁移 +- Home Assistant inbound command gateway - poo records 业务迁移 - Notion 模块 diff --git a/app/config.py b/app/config.py index 021aa8a..e6d8e98 100644 --- a/app/config.py +++ b/app/config.py @@ -22,6 +22,7 @@ class Settings(BaseSettings): home_assistant_base_url: str = "" home_assistant_auth_token: str = "" + home_assistant_timeout_seconds: float = 1.0 home_assistant_action_task_project_id: str = "" model_config = SettingsConfigDict( diff --git a/app/integrations/homeassistant.py b/app/integrations/homeassistant.py index 944839c..d371a17 100644 --- a/app/integrations/homeassistant.py +++ b/app/integrations/homeassistant.py @@ -1,12 +1,108 @@ -from dataclasses import dataclass +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from typing import Any +from urllib import error, parse, request from app.config import Settings +logger = logging.getLogger(__name__) +SUCCESS_STATUS_CODES = {200, 201} + + +class HomeAssistantConfigError(RuntimeError): + """Raised when required Home Assistant outbound configuration is missing.""" + + +class HomeAssistantRequestError(RuntimeError): + """Raised when a Home Assistant outbound HTTP request fails.""" + @dataclass(slots=True) class HomeAssistantClient: settings: Settings + timeout_seconds: float | None = field(default=None) + + def __post_init__(self) -> None: + if self.timeout_seconds is None: + self.timeout_seconds = self.settings.home_assistant_timeout_seconds def is_configured(self) -> bool: return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) + def publish_sensor( + self, + *, + entity_id: str, + state: str, + attributes: dict[str, Any] | None = None, + ) -> None: + self._require_config() + if not entity_id: + raise ValueError("entity_id must not be empty") + + payload = { + "entity_id": entity_id, + "state": state, + "attributes": attributes or {}, + } + self._post_json(f"/api/states/{entity_id}", payload, operation="publish_sensor") + + def trigger_webhook(self, *, webhook_id: str, body: Any) -> None: + self._require_config() + if not webhook_id: + raise ValueError("webhook_id must not be empty") + + self._post_json(f"/api/webhook/{webhook_id}", body, operation="trigger_webhook") + + def _require_config(self) -> None: + if self.is_configured(): + return + raise HomeAssistantConfigError( + "Home Assistant outbound integration is not configured. " + "Set HOME_ASSISTANT_BASE_URL and HOME_ASSISTANT_AUTH_TOKEN." + ) + + def _post_json(self, path: str, payload: Any, *, operation: str) -> None: + url = self._build_url(path) + body = json.dumps(payload).encode("utf-8") + req = request.Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + req.add_header("Authorization", f"Bearer {self.settings.home_assistant_auth_token}") + + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + status_code = response.getcode() + except error.HTTPError as exc: + logger.warning( + "Home Assistant outbound %s failed with HTTP %s for %s", + operation, + exc.code, + url, + ) + raise HomeAssistantRequestError( + f"Home Assistant outbound {operation} failed with HTTP {exc.code}" + ) from exc + except error.URLError as exc: + logger.warning("Home Assistant outbound %s failed for %s: %s", operation, url, exc) + raise HomeAssistantRequestError( + f"Home Assistant outbound {operation} failed to reach Home Assistant" + ) from exc + + if status_code not in SUCCESS_STATUS_CODES: + logger.warning( + "Home Assistant outbound %s returned unexpected status %s for %s", + operation, + status_code, + url, + ) + raise HomeAssistantRequestError( + f"Home Assistant outbound {operation} returned unexpected status {status_code}" + ) + + def _build_url(self, path: str) -> str: + base_url = self.settings.home_assistant_base_url.rstrip("/") + quoted_path = parse.quote(path.lstrip("/"), safe="/") + return f"{base_url}/{quoted_path}" diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index a2c878e..5c5cb9b 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -36,7 +36,8 @@ - `services/` - 业务服务层 - `integrations/` - - 外部系统适配层占位 + - 外部系统适配层 + - 当前已迁入 Home Assistant outbound adapter - `templates/` - Jinja2 模板 - `static/` @@ -76,4 +77,3 @@ Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确 - 不预留 Notion 相关业务流 如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 - diff --git a/docs/homeassistant-outbound.md b/docs/homeassistant-outbound.md new file mode 100644 index 0000000..a24581f --- /dev/null +++ b/docs/homeassistant-outbound.md @@ -0,0 +1,51 @@ +# Home Assistant Outbound Integration + +本文档说明当前 Python 项目中已经迁入的 Home Assistant outbound integration layer。 + +这里的 outbound 指: + +- 由当前 app 主动调用 Home Assistant + +当前不包含: + +- `/homeassistant/publish` +- Home Assistant inbound command gateway +- Home Assistant 驱动当前 app 的入站消息路由 + +## 当前已支持能力 + +当前 `app/integrations/homeassistant.py` 提供一个轻量的 `HomeAssistantClient`,已支持: + +- 发布 / 更新 sensor state + - `POST /api/states/{entity_id}` +- 触发 Home Assistant webhook + - `POST /api/webhook/{webhook_id}` + +这两项能力是按 legacy Go 中 `util/homeassistantutil/homeassistantutil.go` 的出站行为迁入的。 + +## 当前配置 + +当前 outbound adapter 依赖以下配置: + +- `HOME_ASSISTANT_BASE_URL` +- `HOME_ASSISTANT_AUTH_TOKEN` +- `HOME_ASSISTANT_TIMEOUT_SECONDS` + +如果缺少必要配置,client 会直接抛出配置错误,而不是静默跳过。 + +## 错误处理策略 + +当前策略保持保守和简单: + +- 配置缺失:抛出 `HomeAssistantConfigError` +- 参数明显非法:抛出 `ValueError` +- Home Assistant 返回非 200/201:抛出 `HomeAssistantRequestError` +- 网络请求失败:抛出 `HomeAssistantRequestError` + +当前还没有做: + +- 自动重试 +- 熔断 +- 更复杂的 backoff 策略 + +这一轮重点是先把 app -> Home Assistant 的出站契约和可复用结构迁进来。 diff --git a/tests/test_config.py b/tests/test_config.py index eb60c7b..a3349c6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,11 +4,17 @@ from app.config import Settings def test_settings_support_two_independent_database_urls(monkeypatch) -> None: monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") + monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://ha.local:8123") + monkeypatch.setenv("HOME_ASSISTANT_AUTH_TOKEN", "token") + monkeypatch.setenv("HOME_ASSISTANT_TIMEOUT_SECONDS", "2.5") settings = Settings() assert settings.location_database_url == "sqlite:///./data/locationRecorder.db" assert settings.poo_database_url == "sqlite:///./data/pooRecorder.db" + assert settings.home_assistant_base_url == "http://ha.local:8123" + assert settings.home_assistant_auth_token == "token" + assert settings.home_assistant_timeout_seconds == 2.5 assert settings.location_sqlite_path is not None assert settings.location_sqlite_path.name == "locationRecorder.db" assert settings.poo_sqlite_path is not None diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py new file mode 100644 index 0000000..259be39 --- /dev/null +++ b/tests/test_homeassistant.py @@ -0,0 +1,113 @@ +import json +from urllib import error + +import pytest + +from app.config import Settings +from app.integrations.homeassistant import ( + HomeAssistantClient, + HomeAssistantConfigError, + HomeAssistantRequestError, +) + + +class _FakeResponse: + def __init__(self, status_code: int): + self.status_code = status_code + + def getcode(self) -> int: + return self.status_code + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def _configured_settings() -> Settings: + return Settings( + home_assistant_base_url="http://ha.local:8123", + home_assistant_auth_token="secret-token", + home_assistant_timeout_seconds=1.5, + ) + + +def test_publish_sensor_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = HomeAssistantClient( + settings=_configured_settings(), + timeout_seconds=_configured_settings().home_assistant_timeout_seconds, + ) + + def fake_urlopen(req, timeout): + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["authorization"] = req.headers["Authorization"] + captured["content_type"] = req.headers["Content-type"] + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(200) + + monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen) + + client.publish_sensor( + entity_id="sensor.test_poo_status", + state="happy", + attributes={"friendly_name": "Poo Status"}, + ) + + assert captured["url"] == "http://ha.local:8123/api/states/sensor.test_poo_status" + assert captured["timeout"] == pytest.approx(1.5) + assert captured["authorization"] == "Bearer secret-token" + assert captured["content_type"] == "application/json" + assert captured["body"] == { + "entity_id": "sensor.test_poo_status", + "state": "happy", + "attributes": {"friendly_name": "Poo Status"}, + } + + +def test_trigger_webhook_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = HomeAssistantClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + captured["url"] = req.full_url + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeResponse(201) + + monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen) + + client.trigger_webhook(webhook_id="poo-status", body={"status": "done"}) + + assert captured["url"] == "http://ha.local:8123/api/webhook/poo-status" + assert captured["body"] == {"status": "done"} + + +def test_homeassistant_client_raises_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None: + client = HomeAssistantClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + raise error.HTTPError(req.full_url, 500, "boom", hdrs=None, fp=None) + + monkeypatch.setattr("app.integrations.homeassistant.request.urlopen", fake_urlopen) + + with pytest.raises(HomeAssistantRequestError, match="HTTP 500"): + client.publish_sensor(entity_id="sensor.test_status", state="bad") + + +def test_homeassistant_client_raises_when_not_configured() -> None: + client = HomeAssistantClient(settings=Settings()) + + with pytest.raises(HomeAssistantConfigError, match="not configured"): + client.publish_sensor(entity_id="sensor.test_status", state="ok") + + +def test_homeassistant_client_raises_on_invalid_arguments() -> None: + client = HomeAssistantClient(settings=_configured_settings()) + + with pytest.raises(ValueError, match="entity_id"): + client.publish_sensor(entity_id="", state="ok") + + with pytest.raises(ValueError, match="webhook_id"): + client.trigger_webhook(webhook_id="", body={}) From e334df992f8b77e77eb213245ffa1b4d2ed27401 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 10:42:35 +0200 Subject: [PATCH 08/17] Add Home Assistant inbound gateway --- README.md | 8 +- app/api/routes/homeassistant.py | 49 ++++++++ app/api/routes/location.py | 10 +- app/main.py | 2 + app/schemas/homeassistant.py | 9 ++ app/services/homeassistant_inbound.py | 35 ++++++ docs/architecture-overview.md | 1 + docs/homeassistant-inbound.md | 58 ++++++++++ openapi/openapi.json | 19 +++ openapi/openapi.yaml | 12 ++ tests/conftest.py | 20 ++++ tests/test_homeassistant_inbound.py | 160 ++++++++++++++++++++++++++ tests/test_location.py | 26 +---- 13 files changed, 380 insertions(+), 29 deletions(-) create mode 100644 app/api/routes/homeassistant.py create mode 100644 app/schemas/homeassistant.py create mode 100644 app/services/homeassistant_inbound.py create mode 100644 docs/homeassistant-inbound.md create mode 100644 tests/test_homeassistant_inbound.py diff --git a/README.md b/README.md index 328fe20..9cef85c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - 极简 server-side templates - location recorder 第一版迁移 - Home Assistant outbound integration layer +- Home Assistant inbound gateway 第一版 - pytest 测试基础 - OpenAPI 导出脚本 - Docker / Compose 基础骨架 @@ -19,10 +20,15 @@ 当前阶段明确不包含: - TickTick 业务逻辑迁移 -- Home Assistant inbound command gateway - poo records 业务迁移 - Notion 模块 +当前 Home Assistant inbound gateway 仅接回第一版: + +- 已支持 `location_recorder / record` +- 尚未接回 TickTick 路径 +- 尚未接回 poo recorder 路径 + Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 旧 Go 代码位置: diff --git a/app/api/routes/homeassistant.py b/app/api/routes/homeassistant.py new file mode 100644 index 0000000..396c527 --- /dev/null +++ b/app/api/routes/homeassistant.py @@ -0,0 +1,49 @@ +import json +import logging + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import PlainTextResponse, Response +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.dependencies import get_db +from app.schemas.homeassistant import HomeAssistantPublishEnvelope +from app.services.homeassistant_inbound import ( + UnsupportedHomeAssistantMessage, + handle_homeassistant_message, +) + +router = APIRouter(tags=["homeassistant"]) +logger = logging.getLogger(__name__) +BAD_REQUEST_MESSAGE = "bad request" +INTERNAL_SERVER_ERROR_MESSAGE = "internal server error" + + +@router.post("/homeassistant/publish") +async def publish_from_homeassistant( + request: Request, db: Session = Depends(get_db) +) -> Response: + try: + raw_payload = await request.body() + data = json.loads(raw_payload) + envelope = HomeAssistantPublishEnvelope.model_validate(data) + handle_homeassistant_message(db, envelope) + except json.JSONDecodeError as exc: + logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except ValidationError as exc: + logger.warning( + "Rejected Home Assistant publish request due to validation failure: %s", exc + ) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except UnsupportedHomeAssistantMessage as exc: + logger.warning("Home Assistant publish target/action unsupported: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + except ValueError as exc: + logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + + return Response(status_code=status.HTTP_200_OK) diff --git a/app/api/routes/location.py b/app/api/routes/location.py index 6c03bf4..5b87503 100644 --- a/app/api/routes/location.py +++ b/app/api/routes/location.py @@ -1,7 +1,7 @@ import json import logging -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, status from fastapi.responses import PlainTextResponse, Response from pydantic import ValidationError from sqlalchemy.orm import Session @@ -24,12 +24,12 @@ async def create_location_record(request: Request, db: Session = Depends(get_db) record_location(db, payload) except json.JSONDecodeError as exc: logger.warning("Rejected location request due to invalid JSON: %s", exc) - return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) except ValidationError as exc: logger.warning("Rejected location request due to payload validation failure: %s", exc) - return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) except ValueError as exc: logger.warning("Rejected location request due to invalid numeric input: %s", exc) - return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=400) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) - return Response(status_code=200) + return Response(status_code=status.HTTP_200_OK) diff --git a/app/main.py b/app/main.py index e1c3ee5..b77fe37 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from fastapi.staticfiles import StaticFiles from app import models # noqa: F401 from app.api.routes import pages, status +from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.config import get_settings from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db @@ -54,6 +55,7 @@ def create_app() -> FastAPI: app.include_router(status.router) app.include_router(pages.router) + app.include_router(homeassistant_router) app.include_router(location_router) return app diff --git a/app/schemas/homeassistant.py b/app/schemas/homeassistant.py new file mode 100644 index 0000000..da034d6 --- /dev/null +++ b/app/schemas/homeassistant.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict + + +class HomeAssistantPublishEnvelope(BaseModel): + target: str + action: str + content: str + + model_config = ConfigDict(extra="forbid") diff --git a/app/services/homeassistant_inbound.py b/app/services/homeassistant_inbound.py new file mode 100644 index 0000000..eead1f9 --- /dev/null +++ b/app/services/homeassistant_inbound.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json +from sqlalchemy.orm import Session + +from app.schemas.homeassistant import HomeAssistantPublishEnvelope +from app.schemas.location import LocationRecordRequest +from app.services.location import record_location + + +class UnsupportedHomeAssistantMessage(RuntimeError): + """Raised when the inbound gateway receives a target/action that is not supported yet.""" + + +def handle_homeassistant_message( + session: Session, envelope: HomeAssistantPublishEnvelope +) -> None: + if envelope.target == "location_recorder": + _handle_location_message(session, envelope) + return + + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + + +def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnvelope) -> None: + if envelope.action != "record": + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + + content = json.loads(envelope.content.replace("'", '"')) + payload = LocationRecordRequest.model_validate(content) + record_location(session, payload) diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 5c5cb9b..984b701 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -29,6 +29,7 @@ - 通用依赖注入 - `api/` - HTTP routes + - 当前已迁入 `POST /homeassistant/publish` 第一版入口 - `models/` - SQLAlchemy models - `schemas/` diff --git a/docs/homeassistant-inbound.md b/docs/homeassistant-inbound.md new file mode 100644 index 0000000..4a10305 --- /dev/null +++ b/docs/homeassistant-inbound.md @@ -0,0 +1,58 @@ +# Home Assistant Inbound Gateway + +本文档说明当前 Python 项目中已经迁入的 Home Assistant inbound gateway 第一版。 + +这里的 inbound 指: + +- Home Assistant 主动调用当前 app 的入口 + +当前已恢复的入口是: + +- `POST /homeassistant/publish` + +## Request Envelope + +当前沿用 legacy Go 的 envelope 形状: + +```json +{ + "target": "location_recorder", + "action": "record", + "content": "{'person': 'alice', 'latitude': '1.23', 'longitude': '4.56'}" +} +``` + +说明: + +- `target`、`action`、`content` 均为必填 +- unknown field 会被拒绝 +- `content` 当前仍兼容 legacy 常见的单引号 JSON 字符串风格 + +## 当前已支持的 Target / Action + +当前只接回最小可用路径: + +- `location_recorder / record` + +它会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑。 + +## 当前尚未接回 + +以下 legacy 路径在当前阶段还没有迁入: + +- `poo_recorder / get_latest` +- `ticktick / create_action_task` +- 其他未定义 target/action + +这些请求当前会返回: + +- `500 internal server error` + +## 错误处理 + +当前策略保持简洁: + +- envelope 非法、缺字段、unknown field、`content` 非法:返回 `400 bad request` +- target/action 当前未迁入:返回 `500 internal server error` + +对 caller 的响应体保持简洁,不暴露过多内部细节;更详细原因只写日志。 diff --git a/openapi/openapi.json b/openapi/openapi.json index 8f02aa2..bf895d7 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -48,6 +48,25 @@ } } }, + "/homeassistant/publish": { + "post": { + "tags": [ + "homeassistant" + ], + "summary": "Publish From Homeassistant", + "operationId": "publish_from_homeassistant_homeassistant_publish_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, "/location/record": { "post": { "tags": [ diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index f0db5ad..8bf3714 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -31,6 +31,18 @@ paths: text/html: schema: type: string + /homeassistant/publish: + post: + tags: + - homeassistant + summary: Publish From Homeassistant + operationId: publish_from_homeassistant_homeassistant_publish_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} /location/record: post: tags: diff --git a/tests/conftest.py b/tests/conftest.py index 6df20d7..4fd9237 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,10 @@ import pytest from alembic import command from alembic.config import Config from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +import app.db as app_db from app.config import get_settings from app.main import create_app @@ -52,3 +55,20 @@ def app(ready_location_database): def client(app): with TestClient(app) as test_client: yield test_client + + +@pytest.fixture +def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): + database_url = ready_location_database["location_url"] + + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + monkeypatch.setattr(app_db, "engine", engine) + monkeypatch.setattr(app_db, "SessionLocal", session_local) + + fastapi_app = create_app() + with TestClient(fastapi_app) as client: + yield client, engine + + engine.dispose() diff --git a/tests/test_homeassistant_inbound.py b/tests/test_homeassistant_inbound.py new file mode 100644 index 0000000..7adf782 --- /dev/null +++ b/tests/test_homeassistant_inbound.py @@ -0,0 +1,160 @@ +from sqlalchemy import text + + +def test_homeassistant_publish_records_location(location_client) -> None: + client, engine = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": "{'person': 'tianyu', 'latitude': '1.23', 'longitude': '4.56'}", + }, + ) + + assert response.status_code == 200 + assert response.text == "" + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT person, latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.person == "tianyu" + assert row.latitude == 1.23 + assert row.longitude == 4.56 + assert row.altitude == 0.0 + + +def test_homeassistant_publish_records_location_with_altitude(location_client) -> None: + client, engine = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": ( + "{'person': 'tianyu-alt', 'latitude': '1.23', " + "'longitude': '4.56', 'altitude': '7.89'}" + ), + }, + ) + + assert response.status_code == 200 + assert response.text == "" + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT person, latitude, longitude, altitude " + "FROM location ORDER BY datetime DESC LIMIT 1" + ) + ).one() + + assert row.person == "tianyu-alt" + assert row.latitude == 1.23 + assert row.longitude == 4.56 + assert row.altitude == 7.89 + + +def test_homeassistant_publish_rejects_invalid_envelope(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": "{}", + "extra": "not-allowed", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "extra" not in response.text + + +def test_homeassistant_publish_rejects_invalid_json_body(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + content='{"target": "location_recorder", "action": "record", "content": ', + headers={"Content-Type": "application/json"}, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + + +def test_homeassistant_publish_rejects_missing_content(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "content" not in response.text + + +def test_homeassistant_publish_returns_not_implemented_for_unknown_target(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "ticktick", + "action": "create_action_task", + "content": "{}", + }, + ) + + assert response.status_code == 500 + assert response.text == "internal server error" + + +def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action( + location_client, +) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "unknown_action", + "content": "{}", + }, + ) + + assert response.status_code == 500 + assert response.text == "internal server error" + + +def test_homeassistant_publish_rejects_invalid_location_content(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "location_recorder", + "action": "record", + "content": "{'person': 'tianyu', 'latitude': 'bad-lat', 'longitude': '4.56'}", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + assert "bad-lat" not in response.text diff --git a/tests/test_location.py b/tests/test_location.py index 47dee9f..f18696b 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -8,7 +8,7 @@ from alembic.config import Config from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker -import app.db +import app.db as app_db from app.main import create_app from scripts.location_db_adopt import ( EXPECTED_USER_VERSION, @@ -23,26 +23,6 @@ def _make_alembic_config(database_url: str) -> Config: config.set_main_option("sqlalchemy.url", database_url) return config - -@pytest.fixture -def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): - database_url = ready_location_database["location_url"] - - engine = create_engine(database_url, connect_args={"check_same_thread": False}) - session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) - - monkeypatch.setattr(app.db, "engine", engine) - monkeypatch.setattr(app.db, "SessionLocal", session_local) - - from fastapi.testclient import TestClient - - fastapi_app = create_app() - with TestClient(fastapi_app) as client: - yield client, engine - - engine.dispose() - - def test_location_record_endpoint_writes_row(location_client) -> None: client, engine = location_client @@ -243,8 +223,8 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( engine = create_engine(database_url, connect_args={"check_same_thread": False}) session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) - monkeypatch.setattr(app.db, "engine", engine) - monkeypatch.setattr(app.db, "SessionLocal", session_local) + monkeypatch.setattr(app_db, "engine", engine) + monkeypatch.setattr(app_db, "SessionLocal", session_local) from fastapi.testclient import TestClient From 044b47c57306aaabae395b2300bed6bbd26731a0 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 11:48:48 +0200 Subject: [PATCH 09/17] Migrate poo recorder and align Alembic naming --- .env.example | 3 + Dockerfile | 6 +- README.md | 19 +- alembic.ini => alembic_location.ini | 2 +- {alembic => alembic_location}/README | 0 {alembic => alembic_location}/env.py | 0 {alembic => alembic_location}/script.py.mako | 0 .../versions/.gitkeep | 0 .../versions/20260419_01_location_baseline.py | 0 alembic_poo.ini | 37 +++ alembic_poo/env.py | 48 ++++ .../versions/20260420_01_poo_baseline.py | 32 +++ app/api/routes/poo.py | 76 ++++++ app/config.py | 3 + app/dependencies.py | 9 + app/main.py | 15 ++ app/models/poo.py | 13 + app/poo_db.py | 28 ++ app/schemas/poo.py | 9 + app/services/poo.py | 112 ++++++++ docs/architecture-overview.md | 10 +- docs/migration-notes.md | 54 +++- docs/poo-recorder.md | 140 ++++++++++ legacy/README.md | 2 +- openapi/openapi.json | 38 +++ openapi/openapi.yaml | 24 ++ scripts/location_db_adopt.py | 2 +- scripts/poo_db_adopt.py | 172 ++++++++++++ tests/conftest.py | 37 ++- tests/test_app.py | 17 +- tests/test_config.py | 6 + tests/test_homeassistant.py | 2 +- tests/test_location.py | 5 +- tests/test_poo.py | 248 ++++++++++++++++++ 34 files changed, 1138 insertions(+), 31 deletions(-) rename alembic.ini => alembic_location.ini (94%) rename {alembic => alembic_location}/README (100%) rename {alembic => alembic_location}/env.py (100%) rename {alembic => alembic_location}/script.py.mako (100%) rename {alembic => alembic_location}/versions/.gitkeep (100%) rename {alembic => alembic_location}/versions/20260419_01_location_baseline.py (100%) create mode 100644 alembic_poo.ini create mode 100644 alembic_poo/env.py create mode 100644 alembic_poo/versions/20260420_01_poo_baseline.py create mode 100644 app/api/routes/poo.py create mode 100644 app/models/poo.py create mode 100644 app/poo_db.py create mode 100644 app/schemas/poo.py create mode 100644 app/services/poo.py create mode 100644 docs/poo-recorder.md create mode 100644 scripts/poo_db_adopt.py create mode 100644 tests/test_poo.py diff --git a/.env.example b/.env.example index 738bb77..dd04a49 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ APP_HOST=0.0.0.0 APP_PORT=8000 LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db POO_DATABASE_URL=sqlite:///./data/pooRecorder.db +POO_WEBHOOK_ID= +POO_SENSOR_ENTITY_NAME=sensor.test_poo_status +POO_SENSOR_FRIENDLY_NAME=Poo Status TICKTICK_CLIENT_ID= TICKTICK_CLIENT_SECRET= TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback diff --git a/Dockerfile b/Dockerfile index 5ad5727..121d2ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,10 @@ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app ./app -COPY alembic ./alembic -COPY alembic.ini ./ +COPY alembic_location ./alembic_location +COPY alembic_location.ini ./ +COPY alembic_poo ./alembic_poo +COPY alembic_poo.ini ./ COPY scripts ./scripts COPY README.md ./ RUN mkdir -p /app/data diff --git a/README.md b/README.md index 9cef85c..31f5fa0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - SQLite + SQLAlchemy + Alembic 基础设施 - 极简 server-side templates - location recorder 第一版迁移 +- poo recorder 第一版迁移 - Home Assistant outbound integration layer - Home Assistant inbound gateway 第一版 - pytest 测试基础 @@ -20,7 +21,6 @@ 当前阶段明确不包含: - TickTick 业务逻辑迁移 -- poo records 业务迁移 - Notion 模块 当前 Home Assistant inbound gateway 仅接回第一版: @@ -42,21 +42,22 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco 当前系统仍然是两个独立的 SQLite 数据库文件,而不是单一数据库: - `location` 模块使用自己的 DB 文件 -- `poo` 模块未来也将使用自己的 DB 文件 +- `poo` 模块使用自己的 DB 文件 当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点: - `LOCATION_DATABASE_URL` - `POO_DATABASE_URL` -目前真正接入的是 `location` 对应的数据库;`poo` 先保留配置占位,等模块迁入时再接上。 +目前 `location` 和 `poo` 都已经接到各自独立的数据库文件。 ## 当前目录 Python 骨架的主要目录如下: - `app/`: FastAPI 应用代码 -- `alembic/`: Alembic migration 环境 +- `alembic_location/`: Location DB 的 Alembic migration 环境 +- `alembic_poo/`: Poo DB 的 Alembic migration 环境 - `tests/`: pytest 测试 - `docs/`: 架构说明与迁移文档 - `scripts/`: 辅助脚本,例如 OpenAPI 导出 @@ -129,12 +130,12 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 初始化 migration 环境后,可继续添加模型并生成迁移: -```bash -alembic revision --autogenerate -m "init tables" -alembic upgrade head -``` +当前 `location` 和 `poo` 都已经有各自独立的 Alembic baseline / 接管链路。 -当前 Alembic 只接管 `location` 这条链路;`poo` 相关数据库与 migration 还没有迁入。 +- Location Alembic 环境:`alembic_location.ini` + `alembic_location/` +- Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/` +- Location DB 接管 / 初始化:`python scripts/location_db_adopt.py` +- Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py` ## 运行测试 diff --git a/alembic.ini b/alembic_location.ini similarity index 94% rename from alembic.ini rename to alembic_location.ini index b385015..9ee39f0 100644 --- a/alembic.ini +++ b/alembic_location.ini @@ -1,5 +1,5 @@ [alembic] -script_location = alembic +script_location = alembic_location prepend_sys_path = . path_separator = os sqlalchemy.url = sqlite:///./data/locationRecorder.db diff --git a/alembic/README b/alembic_location/README similarity index 100% rename from alembic/README rename to alembic_location/README diff --git a/alembic/env.py b/alembic_location/env.py similarity index 100% rename from alembic/env.py rename to alembic_location/env.py diff --git a/alembic/script.py.mako b/alembic_location/script.py.mako similarity index 100% rename from alembic/script.py.mako rename to alembic_location/script.py.mako diff --git a/alembic/versions/.gitkeep b/alembic_location/versions/.gitkeep similarity index 100% rename from alembic/versions/.gitkeep rename to alembic_location/versions/.gitkeep diff --git a/alembic/versions/20260419_01_location_baseline.py b/alembic_location/versions/20260419_01_location_baseline.py similarity index 100% rename from alembic/versions/20260419_01_location_baseline.py rename to alembic_location/versions/20260419_01_location_baseline.py diff --git a/alembic_poo.ini b/alembic_poo.ini new file mode 100644 index 0000000..cfe1727 --- /dev/null +++ b/alembic_poo.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic_poo +prepend_sys_path = . +path_separator = os +sqlalchemy.url = sqlite:///./data/pooRecorder.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic_poo/env.py b/alembic_poo/env.py new file mode 100644 index 0000000..44cb0b9 --- /dev/null +++ b/alembic_poo/env.py @@ -0,0 +1,48 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import get_settings +from app.models.poo import PooRecord # noqa: F401 +from app.poo_db import PooBase + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +configured_url = config.get_main_option("sqlalchemy.url") +if not configured_url or configured_url == "sqlite:///./data/pooRecorder.db": + config.set_main_option("sqlalchemy.url", settings.poo_database_url) + +target_metadata = PooBase.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic_poo/versions/20260420_01_poo_baseline.py b/alembic_poo/versions/20260420_01_poo_baseline.py new file mode 100644 index 0000000..7abeb90 --- /dev/null +++ b/alembic_poo/versions/20260420_01_poo_baseline.py @@ -0,0 +1,32 @@ +"""poo baseline + +Revision ID: 20260420_01_poo_baseline +Revises: +Create Date: 2026-04-20 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260420_01_poo_baseline" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "poo_records", + sa.Column("timestamp", sa.Text(), nullable=False), + sa.Column("status", sa.Text(), nullable=False), + sa.Column("latitude", sa.Float(), nullable=False), + sa.Column("longitude", sa.Float(), nullable=False), + sa.PrimaryKeyConstraint("timestamp"), + ) + + +def downgrade() -> None: + op.drop_table("poo_records") diff --git a/app/api/routes/poo.py b/app/api/routes/poo.py new file mode 100644 index 0000000..451741d --- /dev/null +++ b/app/api/routes/poo.py @@ -0,0 +1,76 @@ +import json +import logging + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import PlainTextResponse, Response +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from app.config import Settings +from app.dependencies import get_app_settings, get_homeassistant_client, get_poo_db +from app.integrations.homeassistant import HomeAssistantClient +from app.schemas.poo import PooRecordRequest +from app.services.poo import publish_latest_poo_status, record_poo + +router = APIRouter(tags=["poo"]) +logger = logging.getLogger(__name__) +BAD_REQUEST_MESSAGE = "bad request" +INTERNAL_SERVER_ERROR_MESSAGE = "internal server error" + + +@router.post("/poo/record") +async def create_poo_record( + request: Request, + db: Session = Depends(get_poo_db), + settings: Settings = Depends(get_app_settings), + homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client), +) -> Response: + try: + raw_payload = await request.body() + data = json.loads(raw_payload) + payload = PooRecordRequest.model_validate(data) + record_poo( + db, + payload, + settings=settings, + homeassistant_client=homeassistant_client, + ) + except json.JSONDecodeError as exc: + logger.warning("Rejected poo record request due to invalid JSON: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except ValidationError as exc: + logger.warning("Rejected poo record request due to validation failure: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except ValueError as exc: + logger.warning("Rejected poo record request due to invalid numeric input: %s", exc) + return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) + except Exception as exc: + logger.warning("Failed to store poo record: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response(status_code=status.HTTP_200_OK) + + +@router.get("/poo/latest") +def notify_latest_poo( + db: Session = Depends(get_poo_db), + settings: Settings = Depends(get_app_settings), + homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client), +) -> Response: + try: + publish_latest_poo_status( + session=db, + settings=settings, + homeassistant_client=homeassistant_client, + ) + except Exception as exc: + logger.warning("Failed to publish latest poo status: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + return Response(status_code=status.HTTP_200_OK) diff --git a/app/config.py b/app/config.py index e6d8e98..2bc78f4 100644 --- a/app/config.py +++ b/app/config.py @@ -24,6 +24,9 @@ class Settings(BaseSettings): home_assistant_auth_token: str = "" home_assistant_timeout_seconds: float = 1.0 home_assistant_action_task_project_id: str = "" + poo_webhook_id: str = "" + poo_sensor_entity_name: str = "sensor.test_poo_status" + poo_sensor_friendly_name: str = "Poo Status" model_config = SettingsConfigDict( env_file=".env", diff --git a/app/dependencies.py b/app/dependencies.py index fb2f700..fd6e490 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -4,6 +4,8 @@ from sqlalchemy.orm import Session from app.config import Settings, get_settings from app.db import get_db_session +from app.integrations.homeassistant import HomeAssistantClient +from app.poo_db import get_poo_db_session def get_app_settings() -> Settings: @@ -13,3 +15,10 @@ def get_app_settings() -> Settings: def get_db() -> Generator[Session, None, None]: yield from get_db_session() + +def get_poo_db() -> Generator[Session, None, None]: + yield from get_poo_db_session() + + +def get_homeassistant_client() -> HomeAssistantClient: + return HomeAssistantClient(get_settings()) diff --git a/app/main.py b/app/main.py index b77fe37..50e48b2 100644 --- a/app/main.py +++ b/app/main.py @@ -8,8 +8,10 @@ from app import models # noqa: F401 from app.api.routes import pages, status from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router +from app.api.routes.poo import router as poo_router from app.config import get_settings from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db +from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db def ensure_location_db_ready() -> None: @@ -23,6 +25,17 @@ def ensure_location_db_ready() -> None: raise RuntimeError(str(exc)) from exc +def ensure_poo_db_ready() -> None: + settings = get_settings() + if settings.poo_sqlite_path is None: + return + + try: + validate_poo_runtime_db(settings.poo_database_url) + except PooDatabaseAdoptionError as exc: + raise RuntimeError(str(exc)) from exc + + def ensure_runtime_dirs() -> None: settings = get_settings() for path in (settings.location_sqlite_path, settings.poo_sqlite_path): @@ -34,6 +47,7 @@ def ensure_runtime_dirs() -> None: async def lifespan(_: FastAPI): ensure_runtime_dirs() ensure_location_db_ready() + ensure_poo_db_ready() yield @@ -57,6 +71,7 @@ def create_app() -> FastAPI: app.include_router(pages.router) app.include_router(homeassistant_router) app.include_router(location_router) + app.include_router(poo_router) return app diff --git a/app/models/poo.py b/app/models/poo.py new file mode 100644 index 0000000..6476fd8 --- /dev/null +++ b/app/models/poo.py @@ -0,0 +1,13 @@ +from sqlalchemy import Float, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.poo_db import PooBase + + +class PooRecord(PooBase): + __tablename__ = "poo_records" + + timestamp: Mapped[str] = mapped_column(String, primary_key=True) + status: Mapped[str] = mapped_column(String, nullable=False) + latitude: Mapped[float] = mapped_column(Float, nullable=False) + longitude: Mapped[float] = mapped_column(Float, nullable=False) diff --git a/app/poo_db.py b/app/poo_db.py new file mode 100644 index 0000000..3fdda48 --- /dev/null +++ b/app/poo_db.py @@ -0,0 +1,28 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class PooBase(DeclarativeBase): + pass + + +settings = get_settings() + +connect_args: dict[str, object] = {} +if settings.poo_database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + +poo_engine = create_engine(settings.poo_database_url, connect_args=connect_args) +PooSessionLocal = sessionmaker(bind=poo_engine, autoflush=False, autocommit=False, class_=Session) + + +def get_poo_db_session() -> Generator[Session, None, None]: + session = PooSessionLocal() + try: + yield session + finally: + session.close() diff --git a/app/schemas/poo.py b/app/schemas/poo.py new file mode 100644 index 0000000..b24d3f7 --- /dev/null +++ b/app/schemas/poo.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict + + +class PooRecordRequest(BaseModel): + status: str + latitude: str + longitude: str + + model_config = ConfigDict(extra="forbid") diff --git a/app/services/poo.py b/app/services/poo.py new file mode 100644 index 0000000..001a009 --- /dev/null +++ b/app/services/poo.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import logging + +from sqlalchemy import desc, insert, select +from sqlalchemy.orm import Session + +from app.config import Settings +from app.integrations.homeassistant import ( + HomeAssistantClient, + HomeAssistantConfigError, + HomeAssistantRequestError, +) +from app.models.poo import PooRecord +from app.schemas.poo import PooRecordRequest + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class LatestPooRecord: + timestamp: str + status: str + latitude: float + longitude: float + + +def _parse_required_float(value: str, field_name: str) -> float: + try: + return float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid numeric value for {field_name}") from exc + + +def _utc_now_minute_precision() -> str: + now = datetime.now(timezone.utc).replace(second=0, microsecond=0) + return now.strftime("%Y-%m-%dT%H:%MZ") + + +def record_poo( + session: Session, + payload: PooRecordRequest, + *, + settings: Settings, + homeassistant_client: HomeAssistantClient, +) -> None: + stmt = insert(PooRecord).prefix_with("OR IGNORE").values( + timestamp=_utc_now_minute_precision(), + status=payload.status, + latitude=_parse_required_float(payload.latitude, "latitude"), + longitude=_parse_required_float(payload.longitude, "longitude"), + ) + session.execute(stmt) + session.commit() + + try: + publish_latest_poo_status( + session=session, + settings=settings, + homeassistant_client=homeassistant_client, + ) + except (HomeAssistantConfigError, HomeAssistantRequestError) as exc: + logger.warning("Failed to publish latest poo status to Home Assistant: %s", exc) + + if settings.poo_webhook_id: + try: + homeassistant_client.trigger_webhook( + webhook_id=settings.poo_webhook_id, + body={"status": payload.status}, + ) + except (HomeAssistantConfigError, HomeAssistantRequestError) as exc: + logger.warning("Failed to trigger poo webhook on Home Assistant: %s", exc) + + +def get_latest_poo_record(session: Session) -> LatestPooRecord | None: + stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1) + record = session.execute(stmt).scalar_one_or_none() + if record is None: + logger.info("No poo record is available yet") + return None + return LatestPooRecord( + timestamp=record.timestamp, + status=record.status, + latitude=record.latitude, + longitude=record.longitude, + ) + + +def publish_latest_poo_status( + *, + session: Session, + settings: Settings, + homeassistant_client: HomeAssistantClient, +) -> LatestPooRecord | None: + latest = get_latest_poo_record(session) + if latest is None: + logger.info("Skipping Home Assistant poo sensor publish because no poo record exists yet") + return None + + record_time = datetime.fromisoformat(latest.timestamp.replace("Z", "+00:00")).astimezone() + + homeassistant_client.publish_sensor( + entity_id=settings.poo_sensor_entity_name, + state=latest.status, + attributes={ + "last_poo": record_time.strftime("%a | %Y-%m-%d | %H:%M"), + "friendly_name": settings.poo_sensor_friendly_name, + }, + ) + return latest diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 984b701..f646e39 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -30,8 +30,10 @@ - `api/` - HTTP routes - 当前已迁入 `POST /homeassistant/publish` 第一版入口 + - 当前已迁入 `POST /poo/record` 与 `GET /poo/latest` - `models/` - SQLAlchemy models + - 当前 `location` 与 `poo` 使用各自独立的数据库 base - `schemas/` - Pydantic schemas - `services/` @@ -44,9 +46,13 @@ - `static/` - 极简静态资源 -### `alembic/` +### `alembic_location/` -数据库 migration 基础设施。当前尚未迁入业务表,但迁移链路已就绪。 +Location DB 的 migration 基础设施。 + +### `alembic_poo/` + +Poo DB 的 migration 基础设施。 ### `tests/` diff --git a/docs/migration-notes.md b/docs/migration-notes.md index f0d5a3b..ef64a5c 100644 --- a/docs/migration-notes.md +++ b/docs/migration-notes.md @@ -12,6 +12,7 @@ - 建立 Docker / Compose 基础骨架 - 建立 OpenAPI 导出脚本 - 迁入 `location recorder` 第一版 +- 迁入 `poo recorder` 第一版 ## 数据库配置现状 @@ -25,13 +26,12 @@ 其中: - `location` 模块已经实际接到 `LOCATION_DATABASE_URL` -- `poo` 目前只保留 `POO_DATABASE_URL` 配置占位,等待模块迁入 +- `poo` 模块已经实际接到 `POO_DATABASE_URL` ## 当前阶段未做内容 - 未迁移 TickTick 业务逻辑 -- 未迁移 Home Assistant 业务逻辑 -- 未迁移 poo records +- 未迁移 Home Assistant inbound / outbound 之外的其他业务逻辑 - 未实现真实 OAuth 流程 - 未做数据迁移 @@ -74,15 +74,57 @@ CREATE TABLE location ( - DB 尚未被 Alembic 接管时拒绝启动 - DB revision 与当前应用预期不一致时拒绝启动 +## Poo recorder 说明 + +当前 Python 项目已经接入: + +- `POST /poo/record` +- `GET /poo/latest` + +并对齐当前真实 baseline schema: + +```sql +CREATE TABLE poo_records ( + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + PRIMARY KEY (timestamp) +); +``` + +历史上 legacy Go 实现使用: + +```sql +PRAGMA user_version = 1; +``` + +当前已经补上与 location 一致风格的 Alembic baseline / 接管策略: + +- `poo_records` 当前 schema 被视为 Alembic baseline +- 新数据库通过 `alembic_poo upgrade head` 初始化 +- 已有 legacy SQLite 数据库通过 `alembic stamp` 接管 +- `PRAGMA user_version = 1` 仅保留为历史事实,不再作为新的主 migration 机制 + +同时这一轮明确移除了 Notion: + +- 不迁 Notion sync +- 不迁 Notion adapter +- `POST /poo/record` 不再依赖 `tableId` 才能写入 + +详见: + +- [poo-recorder.md](poo-recorder.md) + ## 后续建议顺序 建议继续沿用既有迁移文档中的顺序: 1. 先迁 `location recorder` 2. 再迁 Home Assistant 出站适配层 -3. 再迁 TickTick adapter -4. 再迁 Home Assistant 命令网关 -5. 最后迁 `poo recorder` +3. 再迁 Home Assistant 命令网关 +4. 再迁 `poo recorder` +5. 最后迁 TickTick adapter ## 开发约束提醒 diff --git a/docs/poo-recorder.md b/docs/poo-recorder.md new file mode 100644 index 0000000..94e13e9 --- /dev/null +++ b/docs/poo-recorder.md @@ -0,0 +1,140 @@ +# Poo Recorder + +本文档说明 `poo recorder` 在 Python 项目中的当前行为边界,以及 poo SQLite 的 Alembic 接管策略。 + +## 当前基线 + +当前生产版本中的真实 SQLite schema 为: + +```sql +CREATE TABLE poo_records ( + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + PRIMARY KEY (timestamp) +); +``` + +历史上 legacy Go 实现使用: + +```sql +PRAGMA user_version = 1; +``` + +当前 Python 迁移以这套 schema 为事实基线,不重新设计表结构。 + +## 当前已迁入的 API + +当前 Python 项目已经接入: + +- `POST /poo/record` +- `GET /poo/latest` + +### `POST /poo/record` + +用途: + +- 记录一条 poo event +- 最佳努力地刷新 Home Assistant sensor +- 如果配置了 `POO_WEBHOOK_ID`,最佳努力地触发 Home Assistant webhook + +请求体: + +```json +{ + "status": "done", + "latitude": "1.23", + "longitude": "4.56" +} +``` + +当前策略: + +- unknown field:`400 bad request` +- 数值非法:`400 bad request` +- 记录成功后,即使 Home Assistant side effect 失败,也不会回滚本地 DB 写入 + +### `GET /poo/latest` + +用途: + +- 读取最新一条 poo 记录 +- 将其重新发布到 Home Assistant sensor + +当前外部行为与 legacy 保持一致: + +- 成功:空响应体,HTTP 200 +- 如果当前 DB 里还没有任何 poo 记录:仍返回空响应体,HTTP 200,但不会发布 sensor +- 真正的发布失败:简洁 `internal server error` + +## Home Assistant side effects + +当前已复用 Python 项目中已有的 Home Assistant outbound adapter。 + +当前支持: + +- 发布 / 更新 poo status sensor +- 可选触发 webhook + +相关配置: + +- `HOME_ASSISTANT_BASE_URL` +- `HOME_ASSISTANT_AUTH_TOKEN` +- `HOME_ASSISTANT_TIMEOUT_SECONDS` +- `POO_SENSOR_ENTITY_NAME` +- `POO_SENSOR_FRIENDLY_NAME` +- `POO_WEBHOOK_ID` + +## Alembic 接管策略 + +poo 的接管逻辑刻意保持与 location 一致。 + +当前 baseline revision: + +- `20260420_01_poo_baseline` + +当前提供的脚本入口: + +```bash +python scripts/poo_db_adopt.py +``` + +或: + +```bash +python -m scripts.poo_db_adopt +``` + +规则如下: + +1. 如果本地不存在 poo DB 文件: + - 视为新库初始化 + - 通过 `alembic_poo upgrade head` 创建新库 +2. 如果本地已经存在 legacy DB: + - 先检查 `poo_records` 表 schema + - 再检查 `PRAGMA user_version = 1` + - 只有完全匹配,才通过 Alembic `stamp` 接管 +3. 如果 schema 或 `user_version` 不匹配: + - 直接失败 + - 不自动修复 +4. 如果数据库已经存在 `alembic_version`: + - 只有 revision 与当前 baseline 一致才接受 + - 否则直接失败 + +同时,应用启动时也会对 `POO_DATABASE_URL` 做只读校验: + +- 文件不存在:拒绝启动 +- DB 尚未被 Alembic 接管:拒绝启动 +- revision 不匹配:拒绝启动 + +## 明确移除 Notion + +这一轮不会迁入任何 Notion 逻辑。 + +也就是说,当前 Python 版的 poo recorder: + +- 不保留 Notion adapter +- 不保留 Notion sync +- 不保留 `tableId` 依赖 +- 不因为 legacy 中存在 Notion 就继续保留兼容层 diff --git a/legacy/README.md b/legacy/README.md index e5b1b42..d2a6e33 100644 --- a/legacy/README.md +++ b/legacy/README.md @@ -13,6 +13,6 @@ 原则上: -- 新的 Python 实现继续在仓库根目录的 `app/`、`tests/`、`alembic/` 等目录演进 +- 新的 Python 实现继续在仓库根目录的 `app/`、`tests/`、`alembic_location/`、`alembic_poo/` 等目录演进 - 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础 - 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/` diff --git a/openapi/openapi.json b/openapi/openapi.json index bf895d7..5e920ae 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -85,6 +85,44 @@ } } } + }, + "/poo/record": { + "post": { + "tags": [ + "poo" + ], + "summary": "Create Poo Record", + "operationId": "create_poo_record_poo_record_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/poo/latest": { + "get": { + "tags": [ + "poo" + ], + "summary": "Notify Latest Poo", + "operationId": "notify_latest_poo_poo_latest_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } } }, "components": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 8bf3714..a4c015e 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -55,6 +55,30 @@ paths: content: application/json: schema: {} + /poo/record: + post: + tags: + - poo + summary: Create Poo Record + operationId: create_poo_record_poo_record_post + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + /poo/latest: + get: + tags: + - poo + summary: Notify Latest Poo + operationId: notify_latest_poo_poo_latest_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} components: schemas: StatusResponse: diff --git a/scripts/location_db_adopt.py b/scripts/location_db_adopt.py index 5e3f782..5d8caa9 100644 --- a/scripts/location_db_adopt.py +++ b/scripts/location_db_adopt.py @@ -38,7 +38,7 @@ def _database_path_from_url(database_url: str) -> Path: def _make_alembic_config(database_url: str) -> Config: - config = Config("alembic.ini") + config = Config("alembic_location.ini") config.set_main_option("sqlalchemy.url", database_url) return config diff --git a/scripts/poo_db_adopt.py b/scripts/poo_db_adopt.py new file mode 100644 index 0000000..f571afb --- /dev/null +++ b/scripts/poo_db_adopt.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +from alembic import command +from alembic.config import Config + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.config import get_settings + +POO_BASELINE_REVISION = "20260420_01_poo_baseline" +EXPECTED_USER_VERSION = 1 +EXPECTED_POO_TABLE_INFO = [ + (0, "timestamp", "TEXT", 1, None, 1), + (1, "status", "TEXT", 1, None, 0), + (2, "latitude", "REAL", 1, None, 0), + (3, "longitude", "REAL", 1, None, 0), +] + + +class PooDatabaseAdoptionError(RuntimeError): + """Raised when a legacy poo database does not match the expected baseline.""" + + +def _database_path_from_url(database_url: str) -> Path: + prefix = "sqlite:///" + if not database_url.startswith(prefix): + raise PooDatabaseAdoptionError( + f"Only sqlite URLs are supported for poo DB adoption, got: {database_url}" + ) + return Path(database_url[len(prefix) :]) + + +def _make_alembic_config(database_url: str) -> Config: + config = Config("alembic_poo.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + +def _poo_table_exists(database_path: Path) -> bool: + conn = sqlite3.connect(database_path) + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'poo_records'" + ).fetchone() + return row is not None + finally: + conn.close() + + +def _alembic_version_table_exists(database_path: Path) -> bool: + conn = sqlite3.connect(database_path) + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'" + ).fetchone() + return row is not None + finally: + conn.close() + + +def _fetch_alembic_revision(database_path: Path) -> str: + conn = sqlite3.connect(database_path) + try: + row = conn.execute("SELECT version_num FROM alembic_version").fetchone() + if row is None: + raise PooDatabaseAdoptionError("Alembic version table exists but contains no revision") + return row[0] + finally: + conn.close() + + +def _fetch_poo_table_info(database_path: Path) -> list[tuple]: + conn = sqlite3.connect(database_path) + try: + return list(conn.execute("PRAGMA table_info(poo_records)")) + finally: + conn.close() + + +def _fetch_user_version(database_path: Path) -> int: + conn = sqlite3.connect(database_path) + try: + return conn.execute("PRAGMA user_version").fetchone()[0] + finally: + conn.close() + + +def validate_legacy_poo_db(database_url: str) -> None: + database_path = _database_path_from_url(database_url) + if not database_path.exists(): + raise PooDatabaseAdoptionError(f"Poo DB file does not exist: {database_path}") + + if not _poo_table_exists(database_path): + raise PooDatabaseAdoptionError("Expected table 'poo_records' was not found in the DB") + + table_info = _fetch_poo_table_info(database_path) + if table_info != EXPECTED_POO_TABLE_INFO: + raise PooDatabaseAdoptionError("Poo table schema does not match the expected baseline") + + user_version = _fetch_user_version(database_path) + if user_version != EXPECTED_USER_VERSION: + raise PooDatabaseAdoptionError( + f"Expected PRAGMA user_version = {EXPECTED_USER_VERSION}, got {user_version}" + ) + + +def validate_poo_runtime_db(database_url: str) -> None: + database_path = _database_path_from_url(database_url) + if not database_path.exists(): + raise PooDatabaseAdoptionError( + "Poo DB file was not found. Run 'python scripts/poo_db_adopt.py' first to " + "initialize or adopt the poo DB before starting the app." + ) + + if not _alembic_version_table_exists(database_path): + raise PooDatabaseAdoptionError( + "Poo DB exists but is not yet Alembic-managed. Run " + "'python scripts/poo_db_adopt.py' first to adopt the legacy DB " + "before starting the app." + ) + + current_revision = _fetch_alembic_revision(database_path) + if current_revision != POO_BASELINE_REVISION: + raise PooDatabaseAdoptionError( + "Poo DB revision mismatch. Refusing to start the app: " + f"expected {POO_BASELINE_REVISION}, got {current_revision}" + ) + + +def adopt_or_initialize_poo_db(database_url: str) -> str: + database_path = _database_path_from_url(database_url) + alembic_config = _make_alembic_config(database_url) + + if database_path.exists(): + if _alembic_version_table_exists(database_path): + current_revision = _fetch_alembic_revision(database_path) + if current_revision != POO_BASELINE_REVISION: + raise PooDatabaseAdoptionError( + "Poo DB is already Alembic-managed but revision does not match " + f"the expected baseline: expected {POO_BASELINE_REVISION}, " + f"got {current_revision}" + ) + return "already_managed" + + validate_legacy_poo_db(database_url) + command.stamp(alembic_config, POO_BASELINE_REVISION) + return "adopted" + + database_path.parent.mkdir(parents=True, exist_ok=True) + command.upgrade(alembic_config, "head") + return "initialized" + + +def main() -> None: + settings = get_settings() + result = adopt_or_initialize_poo_db(settings.poo_database_url) + if result == "initialized": + print("Initialized a new poo DB via Alembic upgrade head.") + elif result == "already_managed": + print("Poo DB is already Alembic-managed at the expected baseline revision.") + else: + print("Validated legacy poo DB and stamped Alembic baseline successfully.") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index 4fd9237..68f7508 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,13 @@ from app.main import create_app def _make_alembic_config(database_url: str) -> Config: - config = Config("alembic.ini") + config = Config("alembic_location.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + +def _make_poo_alembic_config(database_url: str) -> Config: + config = Config("alembic_poo.ini") config.set_main_option("sqlalchemy.url", database_url) return config @@ -47,7 +53,13 @@ def ready_location_database(test_database_urls): @pytest.fixture -def app(ready_location_database): +def ready_poo_database(test_database_urls): + command.upgrade(_make_poo_alembic_config(test_database_urls["poo_url"]), "head") + return test_database_urls + + +@pytest.fixture +def app(ready_location_database, ready_poo_database): yield create_app() @@ -58,7 +70,7 @@ def client(app): @pytest.fixture -def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): +def location_client(ready_location_database, ready_poo_database, monkeypatch: pytest.MonkeyPatch): database_url = ready_location_database["location_url"] engine = create_engine(database_url, connect_args={"check_same_thread": False}) @@ -72,3 +84,22 @@ def location_client(ready_location_database, monkeypatch: pytest.MonkeyPatch): yield client, engine engine.dispose() + + +@pytest.fixture +def poo_client(ready_location_database, ready_poo_database, monkeypatch: pytest.MonkeyPatch): + database_url = ready_poo_database["poo_url"] + + engine = create_engine(database_url, connect_args={"check_same_thread": False}) + session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + import app.poo_db as poo_db + + monkeypatch.setattr(poo_db, "poo_engine", engine) + monkeypatch.setattr(poo_db, "PooSessionLocal", session_local) + + fastapi_app = create_app() + with TestClient(fastapi_app) as client: + yield client, engine + + engine.dispose() diff --git a/tests/test_app.py b/tests/test_app.py index 628acfd..ced55c8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -7,7 +7,7 @@ from fastapi.testclient import TestClient from app.config import get_settings from app.main import create_app -from tests.conftest import _make_alembic_config +from tests.conftest import _make_alembic_config, _make_poo_alembic_config async def _run_lifespan(app) -> None: @@ -29,8 +29,11 @@ def test_status_endpoint(client: TestClient) -> None: def test_app_start_fails_when_location_db_missing( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{tmp_path / 'missing.db'}") - monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") get_settings.cache_clear() app = create_app() @@ -43,6 +46,9 @@ def test_app_start_fails_when_location_db_missing( def test_app_start_fails_when_location_db_exists_but_is_not_adopted( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + database_path = tmp_path / "legacy_only.db" conn = sqlite3.connect(database_path) conn.execute( @@ -62,7 +68,7 @@ def test_app_start_fails_when_location_db_exists_but_is_not_adopted( conn.close() monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}") - monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") get_settings.cache_clear() app = create_app() @@ -75,6 +81,9 @@ def test_app_start_fails_when_location_db_exists_but_is_not_adopted( def test_app_start_fails_when_location_db_revision_mismatches( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + database_path = tmp_path / "wrong_revision.db" command.upgrade(_make_alembic_config(f"sqlite:///{database_path}"), "head") @@ -84,7 +93,7 @@ def test_app_start_fails_when_location_db_revision_mismatches( conn.close() monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}") - monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{tmp_path / 'poo_placeholder.db'}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") get_settings.cache_clear() app = create_app() diff --git a/tests/test_config.py b/tests/test_config.py index a3349c6..1eca985 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,9 @@ from app.config import Settings def test_settings_support_two_independent_database_urls(monkeypatch) -> None: monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") + monkeypatch.setenv("POO_WEBHOOK_ID", "poo-hook") + monkeypatch.setenv("POO_SENSOR_ENTITY_NAME", "sensor.test_poo_status") + monkeypatch.setenv("POO_SENSOR_FRIENDLY_NAME", "Poo Status") monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://ha.local:8123") monkeypatch.setenv("HOME_ASSISTANT_AUTH_TOKEN", "token") monkeypatch.setenv("HOME_ASSISTANT_TIMEOUT_SECONDS", "2.5") @@ -12,6 +15,9 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: assert settings.location_database_url == "sqlite:///./data/locationRecorder.db" assert settings.poo_database_url == "sqlite:///./data/pooRecorder.db" + assert settings.poo_webhook_id == "poo-hook" + assert settings.poo_sensor_entity_name == "sensor.test_poo_status" + assert settings.poo_sensor_friendly_name == "Poo Status" assert settings.home_assistant_base_url == "http://ha.local:8123" assert settings.home_assistant_auth_token == "token" assert settings.home_assistant_timeout_seconds == 2.5 diff --git a/tests/test_homeassistant.py b/tests/test_homeassistant.py index 259be39..9dd0bb4 100644 --- a/tests/test_homeassistant.py +++ b/tests/test_homeassistant.py @@ -97,7 +97,7 @@ def test_homeassistant_client_raises_on_http_error(monkeypatch: pytest.MonkeyPat def test_homeassistant_client_raises_when_not_configured() -> None: - client = HomeAssistantClient(settings=Settings()) + client = HomeAssistantClient(settings=Settings(_env_file=None)) with pytest.raises(HomeAssistantConfigError, match="not configured"): client.publish_sensor(entity_id="sensor.test_status", state="ok") diff --git a/tests/test_location.py b/tests/test_location.py index f18696b..c55c80b 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -16,10 +16,11 @@ from scripts.location_db_adopt import ( LocationDatabaseAdoptionError, adopt_or_initialize_location_db, ) +from tests.conftest import _make_poo_alembic_config def _make_alembic_config(database_url: str) -> Config: - config = Config("alembic.ini") + config = Config("alembic_location.ini") config.set_main_option("sqlalchemy.url", database_url) return config @@ -201,6 +202,7 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( ) -> None: database_path = test_database_urls["location_path"] database_url = test_database_urls["location_url"] + poo_database_url = test_database_urls["poo_url"] conn = sqlite3.connect(database_path) conn.execute( @@ -220,6 +222,7 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( conn.close() command.stamp(_make_alembic_config(database_url), LOCATION_BASELINE_REVISION) + command.upgrade(_make_poo_alembic_config(poo_database_url), "head") engine = create_engine(database_url, connect_args={"check_same_thread": False}) session_local = sessionmaker(bind=engine, autoflush=False, autocommit=False) diff --git a/tests/test_poo.py b/tests/test_poo.py new file mode 100644 index 0000000..9e9a4d5 --- /dev/null +++ b/tests/test_poo.py @@ -0,0 +1,248 @@ +from pathlib import Path +import sqlite3 + +import pytest +from sqlalchemy import text + +from app.config import Settings, get_settings +from app.dependencies import get_app_settings, get_homeassistant_client +from scripts.poo_db_adopt import ( + EXPECTED_USER_VERSION, + POO_BASELINE_REVISION, + PooDatabaseAdoptionError, + adopt_or_initialize_poo_db, +) + + +class _FakeHomeAssistantClient: + def __init__(self) -> None: + self.sensor_calls: list[dict] = [] + self.webhook_calls: list[dict] = [] + + def publish_sensor(self, *, entity_id: str, state: str, attributes: dict | None = None) -> None: + self.sensor_calls.append( + {"entity_id": entity_id, "state": state, "attributes": attributes or {}} + ) + + def trigger_webhook(self, *, webhook_id: str, body) -> None: + self.webhook_calls.append({"webhook_id": webhook_id, "body": body}) + + +@pytest.fixture +def poo_client_with_overrides(poo_client): + client, engine = poo_client + fake_ha = _FakeHomeAssistantClient() + settings = Settings( + poo_webhook_id="poo-hook", + poo_sensor_entity_name="sensor.test_poo_status", + poo_sensor_friendly_name="Poo Status", + ) + client.app.dependency_overrides[get_homeassistant_client] = lambda: fake_ha + client.app.dependency_overrides[get_app_settings] = lambda: settings + try: + yield client, engine, fake_ha + finally: + client.app.dependency_overrides.clear() + get_settings.cache_clear() + + +def test_poo_record_endpoint_writes_row_and_notifies_homeassistant( + poo_client_with_overrides, +) -> None: + client, engine, fake_ha = poo_client_with_overrides + + response = client.post( + "/poo/record", + json={ + "status": "done", + "latitude": "1.23", + "longitude": "4.56", + }, + ) + + assert response.status_code == 200 + assert response.text == "" + + with engine.connect() as conn: + row = conn.execute( + text( + "SELECT status, latitude, longitude FROM poo_records " + "ORDER BY timestamp DESC LIMIT 1" + ) + ).one() + + assert row.status == "done" + assert row.latitude == pytest.approx(1.23) + assert row.longitude == pytest.approx(4.56) + assert len(fake_ha.sensor_calls) == 1 + assert fake_ha.sensor_calls[0]["entity_id"] == "sensor.test_poo_status" + assert fake_ha.sensor_calls[0]["state"] == "done" + assert fake_ha.sensor_calls[0]["attributes"]["friendly_name"] == "Poo Status" + assert len(fake_ha.webhook_calls) == 1 + assert fake_ha.webhook_calls[0] == { + "webhook_id": "poo-hook", + "body": {"status": "done"}, + } + + +def test_poo_latest_endpoint_publishes_latest_status(poo_client_with_overrides) -> None: + client, engine, fake_ha = poo_client_with_overrides + + with engine.begin() as conn: + conn.execute( + text( + "INSERT INTO poo_records (timestamp, status, latitude, longitude) " + "VALUES (:timestamp, :status, :latitude, :longitude)" + ), + { + "timestamp": "2026-04-20T10:05Z", + "status": "urgent", + "latitude": 3.21, + "longitude": 6.54, + }, + ) + + response = client.get("/poo/latest") + + assert response.status_code == 200 + assert response.text == "" + assert len(fake_ha.sensor_calls) == 1 + assert fake_ha.sensor_calls[0]["state"] == "urgent" + assert fake_ha.sensor_calls[0]["attributes"]["last_poo"] + + +def test_poo_record_endpoint_rejects_unknown_fields(poo_client_with_overrides) -> None: + client, _, _ = poo_client_with_overrides + + response = client.post( + "/poo/record", + json={ + "status": "done", + "latitude": "1.23", + "longitude": "4.56", + "extra": "nope", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + + +def test_poo_record_endpoint_rejects_invalid_latitude(poo_client_with_overrides) -> None: + client, _, _ = poo_client_with_overrides + + response = client.post( + "/poo/record", + json={ + "status": "done", + "latitude": "oops", + "longitude": "4.56", + }, + ) + + assert response.status_code == 400 + assert response.text == "bad request" + + +def test_poo_latest_endpoint_returns_ok_when_no_record_exists(poo_client_with_overrides) -> None: + client, _, _ = poo_client_with_overrides + + response = client.get("/poo/latest") + + assert response.status_code == 200 + assert response.text == "" + + +def test_poo_db_adoption_initializes_new_db(tmp_path: Path) -> None: + database_path = tmp_path / "new_poo.db" + + result = adopt_or_initialize_poo_db(f"sqlite:///{database_path}") + + assert result == "initialized" + assert database_path.exists() + + conn = sqlite3.connect(database_path) + try: + revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0] + poo_table = conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'poo_records'" + ).fetchone() + finally: + conn.close() + + assert revision == POO_BASELINE_REVISION + assert poo_table is not None + + +def test_poo_db_adoption_validates_and_stamps_legacy_db(tmp_path: Path) -> None: + database_path = tmp_path / "legacy_poo.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE poo_records ( + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + PRIMARY KEY (timestamp) + ) + """ + ) + conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}") + conn.commit() + conn.close() + + result = adopt_or_initialize_poo_db(f"sqlite:///{database_path}") + + assert result == "adopted" + + conn = sqlite3.connect(database_path) + try: + revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0] + finally: + conn.close() + + assert revision == POO_BASELINE_REVISION + + +def test_poo_db_adoption_fails_closed_on_schema_mismatch(tmp_path: Path) -> None: + database_path = tmp_path / "bad_poo_schema.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE poo_records ( + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + latitude REAL NOT NULL, + PRIMARY KEY (timestamp) + ) + """ + ) + conn.execute(f"PRAGMA user_version = {EXPECTED_USER_VERSION}") + conn.commit() + conn.close() + + with pytest.raises(PooDatabaseAdoptionError, match="schema does not match"): + adopt_or_initialize_poo_db(f"sqlite:///{database_path}") + + +def test_poo_db_adoption_fails_closed_on_user_version_mismatch(tmp_path: Path) -> None: + database_path = tmp_path / "bad_poo_user_version.db" + conn = sqlite3.connect(database_path) + conn.execute( + """ + CREATE TABLE poo_records ( + timestamp TEXT NOT NULL, + status TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + PRIMARY KEY (timestamp) + ) + """ + ) + conn.execute("PRAGMA user_version = 999") + conn.commit() + conn.close() + + with pytest.raises(PooDatabaseAdoptionError, match="Expected PRAGMA user_version"): + adopt_or_initialize_poo_db(f"sqlite:///{database_path}") From e1aad408ab737defcfbff7666fb436b8b3ac118d Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 15:16:47 +0200 Subject: [PATCH 10/17] Add auth foundation and app DB management --- .env.example | 6 + Dockerfile | 2 + README.md | 70 +++++- alembic_app.ini | 37 +++ alembic_app/env.py | 48 ++++ alembic_app/script.py.mako | 25 ++ .../versions/20260420_03_app_auth_baseline.py | 56 +++++ app/api/routes/auth.py | 223 +++++++++++++++++ app/api/routes/pages.py | 27 ++- app/auth_db.py | 53 ++++ app/config.py | 18 ++ app/dependencies.py | 16 ++ app/main.py | 23 +- app/models/__init__.py | 3 +- app/models/auth.py | 33 +++ app/services/auth.py | 226 ++++++++++++++++++ app/static/styles.css | 54 ++++- app/templates/admin.html | 64 +++++ app/templates/home.html | 5 +- app/templates/login.html | 33 +++ docs/architecture-overview.md | 9 +- docs/auth.md | 110 +++++++++ openapi/openapi.json | 202 ++++++++++++++++ openapi/openapi.yaml | 130 ++++++++++ scripts/app_db_adopt.py | 135 +++++++++++ tests/conftest.py | 42 +++- tests/test_app.py | 72 +++++- tests/test_auth.py | 112 +++++++++ tests/test_config.py | 16 +- tests/test_location.py | 4 +- 30 files changed, 1834 insertions(+), 20 deletions(-) create mode 100644 alembic_app.ini create mode 100644 alembic_app/env.py create mode 100644 alembic_app/script.py.mako create mode 100644 alembic_app/versions/20260420_03_app_auth_baseline.py create mode 100644 app/api/routes/auth.py create mode 100644 app/auth_db.py create mode 100644 app/models/auth.py create mode 100644 app/services/auth.py create mode 100644 app/templates/admin.html create mode 100644 app/templates/login.html create mode 100644 docs/auth.md create mode 100644 scripts/app_db_adopt.py create mode 100644 tests/test_auth.py diff --git a/.env.example b/.env.example index dd04a49..99100da 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,12 @@ APP_ENV=development APP_DEBUG=true APP_HOST=0.0.0.0 APP_PORT=8000 +APP_DATABASE_URL=sqlite:///./data/app.db +AUTH_BOOTSTRAP_USERNAME=admin +AUTH_BOOTSTRAP_PASSWORD=admin +AUTH_SESSION_COOKIE_NAME=home_automation_session +AUTH_SESSION_TTL_HOURS=12 +AUTH_COOKIE_SECURE_OVERRIDE=false LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db POO_DATABASE_URL=sqlite:///./data/pooRecorder.db POO_WEBHOOK_ID= diff --git a/Dockerfile b/Dockerfile index 121d2ac..bc82a1f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app ./app +COPY alembic_app ./alembic_app +COPY alembic_app.ini ./ COPY alembic_location ./alembic_location COPY alembic_location.ini ./ COPY alembic_poo ./alembic_poo diff --git a/README.md b/README.md index 31f5fa0..7fa1592 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - FastAPI 基础应用骨架 - 环境变量配置体系 - SQLite + SQLAlchemy + Alembic 基础设施 +- username/password + server-side session 基础鉴权 - 极简 server-side templates - location recorder 第一版迁移 - poo recorder 第一版迁移 @@ -39,23 +40,37 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco ## 当前配置现实 -当前系统仍然是两个独立的 SQLite 数据库文件,而不是单一数据库: +当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库: +- `app` 级共享数据使用自己的 DB 文件 - `location` 模块使用自己的 DB 文件 - `poo` 模块使用自己的 DB 文件 -当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点: +当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点: +- `APP_DATABASE_URL` - `LOCATION_DATABASE_URL` - `POO_DATABASE_URL` -目前 `location` 和 `poo` 都已经接到各自独立的数据库文件。 +目前 auth、`location` 和 `poo` 都已经接到各自独立的数据库文件。 + +其中 `app` 级共享 DB 当前主要用于: + +- 单个 admin 用户 +- server-side session + +这部分现在也使用 Alembic 管理: + +- `app db` 不会在应用启动时自动创建 +- 需要先运行 `python scripts/app_db_adopt.py` +- 这个脚本会创建新 DB 并建好 schema ## 当前目录 Python 骨架的主要目录如下: - `app/`: FastAPI 应用代码 +- `alembic_app/`: App DB 的 Alembic migration 环境 - `alembic_location/`: Location DB 的 Alembic migration 环境 - `alembic_poo/`: Poo DB 的 Alembic migration 环境 - `tests/`: pytest 测试 @@ -107,7 +122,15 @@ pip install -r dev-requirements.txt cp .env.example .env ``` -3. 启动服务 +3. 初始化数据库 + +```bash +python scripts/app_db_adopt.py +python scripts/location_db_adopt.py +python scripts/poo_db_adopt.py +``` + +4. 启动服务 ```bash uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 @@ -122,21 +145,54 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 数据库与 Alembic -当前默认仍使用 SQLite,但要明确区分两个数据库文件: +当前默认仍使用 SQLite,但要明确区分三个数据库文件: +- App DB:`sqlite:///./data/app.db` - Location DB:`sqlite:///./data/locationRecorder.db` - Poo DB:`sqlite:///./data/pooRecorder.db` - 数据目录:`./data/` 初始化 migration 环境后,可继续添加模型并生成迁移: -当前 `location` 和 `poo` 都已经有各自独立的 Alembic baseline / 接管链路。 +当前 `app`、`location` 和 `poo` 都已经有各自独立的 Alembic 链路。 +- App Alembic 环境:`alembic_app.ini` + `alembic_app/` - Location Alembic 环境:`alembic_location.ini` + `alembic_location/` - Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/` +- App DB 初始化:`python scripts/app_db_adopt.py` - Location DB 接管 / 初始化:`python scripts/location_db_adopt.py` - Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py` +## 基础鉴权 + +当前项目已经有一层小范围的基础鉴权,目标是先保护后续配置页面,而不是现在就做完整 admin system。 + +- 认证模型:`username/password` +- 会话模型:server-side session + cookie +- 当前受保护页面:`/admin` +- 当前公开页面:`/`、`/login` +- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下 + +安全实现的当前边界: + +- 密码使用 scrypt 做哈希存储 +- session cookie 使用 `HttpOnly` +- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启 +- `SameSite=Lax` +- 登录表单和登出表单都有基础 CSRF 防护 + +首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用: + +- `AUTH_BOOTSTRAP_USERNAME` +- `AUTH_BOOTSTRAP_PASSWORD` + +创建初始 admin 用户。当前默认就是: + +- username: `admin` +- password: `admin` + +首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 + ## 运行测试 ```bash @@ -147,6 +203,7 @@ pytest - app 基本启动测试 - `/status` endpoint 测试 +- 登录 / session 基础流程测试 ## OpenAPI 导出 @@ -200,3 +257,4 @@ SQLite 持久化目录: - [Python 重构方案](docs/python-rewrite-plan.md) - [迁移风险清单](docs/migration-risks.md) - [Location Recorder 接管说明](docs/location-recorder.md) +- [基础鉴权说明](docs/auth.md) diff --git a/alembic_app.ini b/alembic_app.ini new file mode 100644 index 0000000..f6ae3f6 --- /dev/null +++ b/alembic_app.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic_app +prepend_sys_path = . +path_separator = os +sqlalchemy.url = sqlite:///./data/app.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic_app/env.py b/alembic_app/env.py new file mode 100644 index 0000000..66c93f3 --- /dev/null +++ b/alembic_app/env.py @@ -0,0 +1,48 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.auth_db import AuthBase +from app.config import get_settings +from app.models.auth import AuthSession, AuthUser # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +configured_url = config.get_main_option("sqlalchemy.url") +if not configured_url or configured_url == "sqlite:///./data/app.db": + config.set_main_option("sqlalchemy.url", settings.app_database_url) + +target_metadata = AuthBase.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic_app/script.py.mako b/alembic_app/script.py.mako new file mode 100644 index 0000000..a9941d2 --- /dev/null +++ b/alembic_app/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic_app/versions/20260420_03_app_auth_baseline.py b/alembic_app/versions/20260420_03_app_auth_baseline.py new file mode 100644 index 0000000..200e497 --- /dev/null +++ b/alembic_app/versions/20260420_03_app_auth_baseline.py @@ -0,0 +1,56 @@ +"""app auth baseline + +Revision ID: 20260420_03_app_auth_baseline +Revises: +Create Date: 2026-04-20 00:00:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260420_03_app_auth_baseline" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "auth_users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("username", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("force_password_change", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_auth_users_username"), "auth_users", ["username"], unique=True) + + op.create_table( + "auth_sessions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("csrf_token", sa.String(length=128), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["auth_users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_auth_sessions_expires_at"), "auth_sessions", ["expires_at"], unique=False) + op.create_index(op.f("ix_auth_sessions_token_hash"), "auth_sessions", ["token_hash"], unique=True) + op.create_index(op.f("ix_auth_sessions_user_id"), "auth_sessions", ["user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_auth_sessions_user_id"), table_name="auth_sessions") + op.drop_index(op.f("ix_auth_sessions_token_hash"), table_name="auth_sessions") + op.drop_index(op.f("ix_auth_sessions_expires_at"), table_name="auth_sessions") + op.drop_table("auth_sessions") + op.drop_index(op.f("ix_auth_users_username"), table_name="auth_users") + op.drop_table("auth_users") diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py new file mode 100644 index 0000000..b1280f3 --- /dev/null +++ b/app/api/routes/auth.py @@ -0,0 +1,223 @@ +import logging +from pathlib import Path + +from fastapi import APIRouter, Depends, Form, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from app.config import Settings +from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session +from app.services.auth import ( + AuthenticatedSession, + authenticate_user, + change_password, + create_session, + AuthPasswordChangeError, + issue_login_csrf_token, + revoke_session, + validate_csrf_token, +) + +logger = logging.getLogger(__name__) +templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) +router = APIRouter(tags=["auth"]) + +LOGIN_CSRF_COOKIE_NAME = "login_csrf" + + +@router.get("/login", response_class=HTMLResponse) +def login_page( + request: Request, + settings: Settings = Depends(get_app_settings), + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> Response: + if current_auth is not None: + return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + + csrf_token = issue_login_csrf_token() + response = templates.TemplateResponse( + request, + "login.html", + { + "app_name": settings.app_name, + "app_env": settings.app_env, + "csrf_token": csrf_token, + "error_message": None, + }, + ) + _set_login_csrf_cookie(response, settings=settings, token=csrf_token) + return response + + +@router.post("/login", response_class=HTMLResponse) +def login_submit( + request: Request, + username: str = Form(), + password: str = Form(), + csrf_token: str = Form(), + session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), +) -> Response: + cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME) + if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token): + logger.warning("Rejected login attempt due to CSRF validation failure") + return _render_login_error( + request, + settings=settings, + status_code=status.HTTP_400_BAD_REQUEST, + error_message="invalid login request", + ) + + user = authenticate_user(session, username=username, password=password) + if user is None: + return _render_login_error( + request, + settings=settings, + status_code=status.HTTP_401_UNAUTHORIZED, + error_message="invalid username or password", + ) + + auth_session, raw_token = create_session(session, user=user, settings=settings) + response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login") + response.set_cookie( + key=settings.auth_session_cookie_name, + value=raw_token, + max_age=settings.auth_session_ttl_hours * 3600, + httponly=True, + secure=settings.auth_cookie_secure, + samesite="lax", + path="/", + ) + logger.info("Created authenticated session for user '%s'", user.username) + return response + + +@router.post("/admin/change-password", response_class=HTMLResponse) +def change_password_submit( + request: Request, + current_password: str = Form(), + new_password: str = Form(), + confirm_password: str = Form(), + csrf_token: str = Form(), + session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token): + logger.warning("Rejected password change attempt due to CSRF validation failure") + return _render_admin_page( + request, + settings=settings, + current_auth=current_auth, + status_code=status.HTTP_400_BAD_REQUEST, + password_change_error="invalid password change request", + ) + + try: + change_password( + session, + user=current_auth.user, + current_password=current_password, + new_password=new_password, + confirm_password=confirm_password, + ) + except AuthPasswordChangeError as exc: + logger.info( + "Rejected password change for user '%s': %s", + current_auth.user.username, + exc, + ) + return _render_admin_page( + request, + settings=settings, + current_auth=current_auth, + status_code=status.HTTP_400_BAD_REQUEST, + password_change_error="password change failed", + ) + + logger.info("Password updated for user '%s'", current_auth.user.username) + return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/logout") +def logout( + request: Request, + csrf_token: str = Form(), + session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> RedirectResponse: + if current_auth is not None and validate_csrf_token( + expected=current_auth.session.csrf_token, actual=csrf_token + ): + revoke_session(session, auth_session=current_auth.session) + logger.info("Revoked authenticated session for user '%s'", current_auth.user.username) + else: + logger.warning("Rejected logout request due to missing session or invalid CSRF token") + + response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + response.delete_cookie(settings.auth_session_cookie_name, path="/") + return response + + +def _render_login_error( + request: Request, + *, + settings: Settings, + status_code: int, + error_message: str, +) -> HTMLResponse: + csrf_token = issue_login_csrf_token() + response = templates.TemplateResponse( + request, + "login.html", + { + "app_name": settings.app_name, + "app_env": settings.app_env, + "csrf_token": csrf_token, + "error_message": error_message, + }, + status_code=status_code, + ) + _set_login_csrf_cookie(response, settings=settings, token=csrf_token) + return response + + +def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None: + response.set_cookie( + key=LOGIN_CSRF_COOKIE_NAME, + value=token, + max_age=1800, + httponly=True, + secure=settings.auth_cookie_secure, + samesite="lax", + path="/login", + ) + + +def _render_admin_page( + request: Request, + *, + settings: Settings, + current_auth: AuthenticatedSession, + status_code: int, + password_change_error: str | None, +) -> HTMLResponse: + return templates.TemplateResponse( + request, + "admin.html", + { + "app_name": settings.app_name, + "app_env": settings.app_env, + "current_username": current_auth.user.username, + "csrf_token": current_auth.session.csrf_token, + "force_password_change": current_auth.user.force_password_change, + "password_change_error": password_change_error, + }, + status_code=status_code, + ) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index 2bca83b..9fc4c25 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -1,11 +1,12 @@ from pathlib import Path -from fastapi import APIRouter, Depends, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates from app.config import Settings -from app.dependencies import get_app_settings +from app.dependencies import get_app_settings, get_current_auth_session +from app.services.auth import AuthenticatedSession templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) router = APIRouter(tags=["pages"]) @@ -19,3 +20,23 @@ def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HT "notion_status": "Legacy scope, removed from the Python rewrite target.", } return templates.TemplateResponse(request, "home.html", context) + + +@router.get("/admin", response_class=HTMLResponse) +def admin_page( + request: Request, + settings: Settings = Depends(get_app_settings), + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + context = { + "app_name": settings.app_name, + "app_env": settings.app_env, + "current_username": current_auth.user.username, + "csrf_token": current_auth.session.csrf_token, + "force_password_change": current_auth.user.force_password_change, + "password_change_error": None, + } + return templates.TemplateResponse(request, "admin.html", context) diff --git a/app/auth_db.py b/app/auth_db.py new file mode 100644 index 0000000..41dcd1f --- /dev/null +++ b/app/auth_db.py @@ -0,0 +1,53 @@ +from collections.abc import Generator +from functools import lru_cache + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class AuthBase(DeclarativeBase): + pass + + +def _build_connect_args(database_url: str) -> dict[str, object]: + connect_args: dict[str, object] = {} + if database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + return connect_args + + +@lru_cache +def _get_auth_engine(database_url: str): + return create_engine(database_url, connect_args=_build_connect_args(database_url)) + + +@lru_cache +def _get_auth_session_local(database_url: str): + engine = _get_auth_engine(database_url) + return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) + + +def get_auth_engine(): + settings = get_settings() + return _get_auth_engine(settings.app_database_url) + + +def get_auth_session_local(): + settings = get_settings() + return _get_auth_session_local(settings.app_database_url) + + +def reset_auth_db_caches() -> None: + _get_auth_session_local.cache_clear() + _get_auth_engine.cache_clear() + + +def get_auth_db_session() -> Generator[Session, None, None]: + session_local = get_auth_session_local() + session = session_local() + try: + yield session + finally: + session.close() diff --git a/app/config.py b/app/config.py index 2bc78f4..7c5aa78 100644 --- a/app/config.py +++ b/app/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): app_debug: bool = False app_host: str = "0.0.0.0" app_port: int = 8000 + app_database_url: str = "sqlite:///./data/app.db" location_database_url: str = "sqlite:///./data/locationRecorder.db" poo_database_url: str = "sqlite:///./data/pooRecorder.db" @@ -27,6 +28,11 @@ class Settings(BaseSettings): poo_webhook_id: str = "" poo_sensor_entity_name: str = "sensor.test_poo_status" poo_sensor_friendly_name: str = "Poo Status" + auth_bootstrap_username: str = "admin" + auth_bootstrap_password: str = "admin" + auth_session_cookie_name: str = "home_automation_session" + auth_session_ttl_hours: int = 12 + auth_cookie_secure_override: bool | None = None model_config = SettingsConfigDict( env_file=".env", @@ -52,11 +58,23 @@ class Settings(BaseSettings): def location_sqlite_path(self) -> Path | None: return self._sqlite_path_from_url(self.location_database_url) + @computed_field + @property + def app_sqlite_path(self) -> Path | None: + return self._sqlite_path_from_url(self.app_database_url) + @computed_field @property def poo_sqlite_path(self) -> Path | None: return self._sqlite_path_from_url(self.poo_database_url) + @computed_field + @property + def auth_cookie_secure(self) -> bool: + if self.auth_cookie_secure_override is not None: + return self.auth_cookie_secure_override + return not self.is_development + @lru_cache def get_settings() -> Settings: diff --git a/app/dependencies.py b/app/dependencies.py index fd6e490..e035990 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,17 +1,24 @@ from collections.abc import Generator +from fastapi import Depends, Request from sqlalchemy.orm import Session +from app.auth_db import get_auth_db_session from app.config import Settings, get_settings from app.db import get_db_session from app.integrations.homeassistant import HomeAssistantClient from app.poo_db import get_poo_db_session +from app.services.auth import AuthenticatedSession, get_authenticated_session def get_app_settings() -> Settings: return get_settings() +def get_auth_db() -> Generator[Session, None, None]: + yield from get_auth_db_session() + + def get_db() -> Generator[Session, None, None]: yield from get_db_session() @@ -22,3 +29,12 @@ def get_poo_db() -> Generator[Session, None, None]: def get_homeassistant_client() -> HomeAssistantClient: return HomeAssistantClient(get_settings()) + + +def get_current_auth_session( + request: Request, + session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), +) -> AuthenticatedSession | None: + raw_token = request.cookies.get(settings.auth_session_cookie_name) + return get_authenticated_session(session, raw_token=raw_token) diff --git a/app/main.py b/app/main.py index 50e48b2..60af9be 100644 --- a/app/main.py +++ b/app/main.py @@ -3,17 +3,36 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles +from sqlalchemy.orm import Session from app import models # noqa: F401 +from app.api.routes.auth import router as auth_router from app.api.routes import pages, status +import app.auth_db as auth_db from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router from app.config import get_settings +from app.services.auth import AuthBootstrapError, initialize_auth_schema +from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db +def ensure_auth_db_ready() -> None: + session_local = auth_db.get_auth_session_local() + session: Session = session_local() + try: + validate_app_runtime_db(get_settings().app_database_url) + initialize_auth_schema(session, get_settings()) + except AppDatabaseAdoptionError as exc: + raise RuntimeError(str(exc)) from exc + except AuthBootstrapError as exc: + raise RuntimeError(str(exc)) from exc + finally: + session.close() + + def ensure_location_db_ready() -> None: settings = get_settings() if settings.location_sqlite_path is None: @@ -38,7 +57,7 @@ def ensure_poo_db_ready() -> None: def ensure_runtime_dirs() -> None: settings = get_settings() - for path in (settings.location_sqlite_path, settings.poo_sqlite_path): + for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path): if path is not None: path.parent.mkdir(parents=True, exist_ok=True) @@ -46,6 +65,7 @@ def ensure_runtime_dirs() -> None: @asynccontextmanager async def lifespan(_: FastAPI): ensure_runtime_dirs() + ensure_auth_db_ready() ensure_location_db_ready() ensure_poo_db_ready() yield @@ -68,6 +88,7 @@ def create_app() -> FastAPI: app.mount("/static", StaticFiles(directory=static_dir), name="static") app.include_router(status.router) + app.include_router(auth_router) app.include_router(pages.router) app.include_router(homeassistant_router) app.include_router(location_router) diff --git a/app/models/__init__.py b/app/models/__init__.py index ae09e84..76f3041 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,5 +1,6 @@ """SQLAlchemy models package.""" +from app.models.auth import AuthSession, AuthUser from app.models.location import Location -__all__ = ["Location"] +__all__ = ["AuthSession", "AuthUser", "Location"] diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..3284913 --- /dev/null +++ b/app/models/auth.py @@ -0,0 +1,33 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.auth_db import AuthBase + + +class AuthUser(AuthBase): + __tablename__ = "auth_users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + force_password_change: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user") + + +class AuthSession(AuthBase): + __tablename__ = "auth_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("auth_users.id"), nullable=False, index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + csrf_token: Mapped[str] = mapped_column(String(128), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + user: Mapped[AuthUser] = relationship(back_populates="sessions") diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..feaca82 --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import base64 +import hashlib +import logging +import secrets +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from sqlalchemy import Select, select +from sqlalchemy.orm import Session + +from app.config import Settings +from app.models.auth import AuthSession, AuthUser + +logger = logging.getLogger(__name__) + +SCRYPT_N = 2**14 +SCRYPT_R = 8 +SCRYPT_P = 1 +SCRYPT_DKLEN = 64 + + +class AuthBootstrapError(RuntimeError): + """Raised when the auth system cannot be safely initialized.""" + + +class AuthPasswordChangeError(ValueError): + """Raised when a password change request is invalid.""" + + +@dataclass(slots=True) +class AuthenticatedSession: + user: AuthUser + session: AuthSession + + +def initialize_auth_schema(session: Session, settings: Settings) -> None: + has_any_user = session.scalar(select(AuthUser.id).limit(1)) is not None + if has_any_user: + return + + if not settings.auth_bootstrap_username or not settings.auth_bootstrap_password: + raise AuthBootstrapError( + "Auth DB has no users. Set AUTH_BOOTSTRAP_USERNAME and " + "AUTH_BOOTSTRAP_PASSWORD before starting the app." + ) + + bootstrap_user = AuthUser( + username=settings.auth_bootstrap_username, + password_hash=hash_password(settings.auth_bootstrap_password), + is_active=True, + force_password_change=True, + created_at=_utc_now(), + ) + session.add(bootstrap_user) + session.commit() + logger.warning( + "Bootstrapped initial auth user '%s'. Rotate AUTH_BOOTSTRAP_PASSWORD after first setup.", + bootstrap_user.username, + ) + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + derived_key = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=SCRYPT_N, + r=SCRYPT_R, + p=SCRYPT_P, + dklen=SCRYPT_DKLEN, + ) + return "$".join( + [ + "scrypt", + str(SCRYPT_N), + str(SCRYPT_R), + str(SCRYPT_P), + base64.b64encode(salt).decode("ascii"), + base64.b64encode(derived_key).decode("ascii"), + ] + ) + + +def verify_password(password: str, stored_hash: str) -> bool: + try: + algorithm, n, r, p, encoded_salt, encoded_key = stored_hash.split("$") + except ValueError: + return False + + if algorithm != "scrypt": + return False + + try: + salt = base64.b64decode(encoded_salt.encode("ascii")) + expected_key = base64.b64decode(encoded_key.encode("ascii")) + derived_key = hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=int(n), + r=int(r), + p=int(p), + dklen=len(expected_key), + ) + except (ValueError, TypeError): + return False + + return secrets.compare_digest(derived_key, expected_key) + + +def authenticate_user(session: Session, *, username: str, password: str) -> AuthUser | None: + user = session.scalar(select(AuthUser).where(AuthUser.username == username).limit(1)) + if user is None or not user.is_active: + logger.info("Failed login for unknown or inactive user '%s'", username) + return None + + if not verify_password(password, user.password_hash): + logger.info("Failed login due to invalid password for user '%s'", username) + return None + + return user + + +def create_session(session: Session, *, user: AuthUser, settings: Settings) -> tuple[AuthSession, str]: + raw_token = secrets.token_urlsafe(32) + auth_session = AuthSession( + user_id=user.id, + token_hash=_hash_token(raw_token), + csrf_token=secrets.token_urlsafe(24), + created_at=_utc_now(), + expires_at=_utc_now() + timedelta(hours=settings.auth_session_ttl_hours), + revoked_at=None, + ) + session.add(auth_session) + session.commit() + session.refresh(auth_session) + return auth_session, raw_token + + +def get_authenticated_session(session: Session, *, raw_token: str | None) -> AuthenticatedSession | None: + if not raw_token: + return None + + stmt: Select[tuple[AuthSession, AuthUser]] = ( + select(AuthSession, AuthUser) + .join(AuthUser, AuthSession.user_id == AuthUser.id) + .where(AuthSession.token_hash == _hash_token(raw_token)) + .limit(1) + ) + result = session.execute(stmt).first() + if result is None: + return None + + auth_session, user = result + now = _utc_now() + expires_at = _as_utc(auth_session.expires_at) + revoked_at = _as_utc(auth_session.revoked_at) + if revoked_at is not None or expires_at <= now or not user.is_active: + if revoked_at is None and expires_at <= now: + auth_session.revoked_at = now + session.commit() + return None + + return AuthenticatedSession(user=user, session=auth_session) + + +def revoke_session(session: Session, *, auth_session: AuthSession) -> None: + if auth_session.revoked_at is not None: + return + auth_session.revoked_at = _utc_now() + session.commit() + + +def change_password( + session: Session, + *, + user: AuthUser, + current_password: str, + new_password: str, + confirm_password: str, +) -> None: + if not verify_password(current_password, user.password_hash): + raise AuthPasswordChangeError("current password is invalid") + + if not new_password: + raise AuthPasswordChangeError("new password must not be empty") + + if new_password != confirm_password: + raise AuthPasswordChangeError("new password confirmation does not match") + + if len(new_password) < 8: + raise AuthPasswordChangeError("new password must be at least 8 characters long") + + if verify_password(new_password, user.password_hash): + raise AuthPasswordChangeError("new password must be different from the current password") + + user.password_hash = hash_password(new_password) + user.force_password_change = False + session.commit() + + +def issue_login_csrf_token() -> str: + return secrets.token_urlsafe(24) + + +def validate_csrf_token(*, expected: str | None, actual: str | None) -> bool: + if not expected or not actual: + return False + return secrets.compare_digest(expected, actual) + + +def _hash_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + + +def _utc_now() -> datetime: + return datetime.now(UTC) + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) diff --git a/app/static/styles.css b/app/static/styles.css index eddcec4..986181b 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -83,6 +83,59 @@ a { color: var(--accent); } +.auth-panel { + max-width: 520px; + margin-inline: auto; +} + +.auth-form, +.logout-form { + display: grid; + gap: 16px; +} + +.auth-form label { + display: grid; + gap: 8px; + font-size: 0.95rem; + color: var(--muted); +} + +.auth-form input { + width: 100%; + padding: 12px 14px; + border: 1px solid rgba(31, 41, 51, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.92); + color: var(--text); + font: inherit; +} + +button { + width: fit-content; + min-width: 120px; + padding: 12px 18px; + border: none; + border-radius: 999px; + background: var(--accent); + color: white; + font: inherit; + cursor: pointer; +} + +button:hover { + filter: brightness(1.04); +} + +.alert { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(157, 37, 37, 0.08); + border: 1px solid rgba(157, 37, 37, 0.14); + color: #8b2a2a; +} + @media (max-width: 640px) { .shell { margin: 24px auto; @@ -92,4 +145,3 @@ a { padding: 24px; } } - diff --git a/app/templates/admin.html b/app/templates/admin.html new file mode 100644 index 0000000..0f82101 --- /dev/null +++ b/app/templates/admin.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}Admin · {{ app_name }}{% endblock %} + +{% block content %} +
+

Protected Area

+

Admin

+ {% if force_password_change %} +

+ 首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。 +

+ + {% if password_change_error %} +
{{ password_change_error }}
+ {% endif %} + +
+ + + + + + + + + +
+ {% else %} +

+ 你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。 +

+ +
+
+
当前用户
+
{{ current_username }}
+
+
+
运行环境
+
{{ app_env }}
+
+
+
下一步
+
在这里接入配置页面与更细的受保护操作。
+
+
+ {% endif %} + +
+ + +
+
+{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html index 559a65a..63ef3aa 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -23,6 +23,10 @@
OpenAPI
/docs
+
+
登录
+
/login
+
Notion
{{ notion_status }}
@@ -30,4 +34,3 @@ {% endblock %} - diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..a3a3310 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}登录 · {{ app_name }}{% endblock %} + +{% block content %} +
+

Authentication

+

登录

+

+ 这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。 +

+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + +
+ + + + + + + +
+
+{% endblock %} diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index f646e39..1e415e9 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -23,17 +23,20 @@ - 基础路由注册 - `config.py` - 环境变量驱动的 settings +- `auth_db.py` + - app 级共享 auth 数据库 - `db.py` - SQLAlchemy engine / session / Base - `dependencies.py` - 通用依赖注入 - `api/` - HTTP routes + - 当前已迁入 `/login`、`/logout`、`/admin` - 当前已迁入 `POST /homeassistant/publish` 第一版入口 - 当前已迁入 `POST /poo/record` 与 `GET /poo/latest` - `models/` - SQLAlchemy models - - 当前 `location` 与 `poo` 使用各自独立的数据库 base + - 当前 `auth`、`location` 与 `poo` 使用各自独立的数据库 base - `schemas/` - Pydantic schemas - `services/` @@ -50,6 +53,10 @@ Location DB 的 migration 基础设施。 +### `alembic_app/` + +App DB 的 migration 基础设施。 + ### `alembic_poo/` Poo DB 的 migration 基础设施。 diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..bcce2d6 --- /dev/null +++ b/docs/auth.md @@ -0,0 +1,110 @@ +# 基础鉴权说明 + +本文档说明当前 Python 重构项目里已经落地的第一版鉴权基座。 + +这一轮只解决: + +- 登录页 +- 登录 / 登出流程 +- server-side session +- 一个最小受保护页面 + +这一轮明确不解决: + +- 完整 config persistence +- 完整 config CRUD +- 多用户权限系统 +- OAuth / SSO / RBAC + +## 当前 auth 模型 + +- 认证方式:`username/password` +- 会话方式:server-side session +- 客户端凭据:session cookie +- 页面形态:Jinja server-side template + +## 当前持久化 + +当前新增一个共享 App DB: + +- `APP_DATABASE_URL` +- 默认值:`sqlite:///./data/app.db` + +当前 auth 相关数据存放在这个 DB 中: + +- `auth_users` +- `auth_sessions` + +当前没有把 auth 数据和 `location` / `poo` DB 混放。 + +当前这部分现在也走 Alembic 管理: + +- Alembic 环境:`alembic_app.ini` + `alembic_app/` +- 初始化脚本:`python scripts/app_db_adopt.py` + +当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。 + +## 首次启动与 bootstrap + +如果 auth DB 中还没有任何用户,应用启动时会要求: + +- `AUTH_BOOTSTRAP_USERNAME` +- `AUTH_BOOTSTRAP_PASSWORD` + +并创建首个 admin 用户。 + +当前默认 bootstrap 值就是: + +- username: `admin` +- password: `admin` + +首次登录后,系统会强制要求修改密码。 + +如果你希望在首次启动前就覆盖默认值,可以直接设置环境变量: + +- `AUTH_BOOTSTRAP_USERNAME` +- `AUTH_BOOTSTRAP_PASSWORD` + +建议流程是: + +1. 配好 `.env` +2. 运行 `python scripts/app_db_adopt.py` +3. 启动应用 +4. 用 `admin / admin` 首次登录 +5. 立即修改密码 + +## 安全设计 + +当前这版已经落实的基础安全点: + +- 密码不明文存储,使用 scrypt 哈希 +- session cookie 为 `HttpOnly` +- cookie 使用 `SameSite=Lax` +- `Secure` cookie 在非 `development` 环境默认开启 +- 登录表单与登出表单都有基础 CSRF 校验 +- session token 为随机生成,服务端只持久化 token hash +- session 有过期时间与显式失效机制 + +## 当前受保护范围 + +当前这轮只保护了页面入口: + +- `GET /admin` +- `POST /admin/change-password` +- `POST /logout` + +相关流程: + +- `GET /login` +- `POST /login` + +未登录访问 `/admin` 时会被重定向到 `/login`。 + +## 下一步不在本轮内 + +后续可以在这个基座上继续做: + +- 配置页面接入 +- config persistence +- 更细的受保护路由范围 +- 用户初始化 / 密码轮换的更正式 runbook diff --git a/openapi/openapi.json b/openapi/openapi.json index 5e920ae..03c50e9 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -27,6 +27,105 @@ } } }, + "/login": { + "get": { + "tags": [ + "auth" + ], + "summary": "Login Page", + "operationId": "login_page_login_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "auth" + ], + "summary": "Login Submit", + "operationId": "login_submit_login_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_login_submit_login_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Logout", + "operationId": "logout_logout_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_logout_logout_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/": { "get": { "tags": [ @@ -48,6 +147,27 @@ } } }, + "/admin": { + "get": { + "tags": [ + "pages" + ], + "summary": "Admin Page", + "operationId": "admin_page_admin_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/homeassistant/publish": { "post": { "tags": [ @@ -127,6 +247,55 @@ }, "components": { "schemas": { + "Body_login_submit_login_post": { + "properties": { + "username": { + "type": "string", + "title": "Username" + }, + "password": { + "type": "string", + "title": "Password" + }, + "csrf_token": { + "type": "string", + "title": "Csrf Token" + } + }, + "type": "object", + "required": [ + "username", + "password", + "csrf_token" + ], + "title": "Body_login_submit_login_post" + }, + "Body_logout_logout_post": { + "properties": { + "csrf_token": { + "type": "string", + "title": "Csrf Token" + } + }, + "type": "object", + "required": [ + "csrf_token" + ], + "title": "Body_logout_logout_post" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, "StatusResponse": { "properties": { "status": { @@ -139,6 +308,39 @@ "status" ], "title": "StatusResponse" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" } } } diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a4c015e..4939152 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -18,6 +18,67 @@ paths: application/json: schema: $ref: '#/components/schemas/StatusResponse' + /login: + get: + tags: + - auth + summary: Login Page + operationId: login_page_login_get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + post: + tags: + - auth + summary: Login Submit + operationId: login_submit_login_post + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Body_login_submit_login_post' + required: true + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /logout: + post: + tags: + - auth + summary: Logout + operationId: logout_logout_post + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Body_logout_logout_post' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /: get: tags: @@ -31,6 +92,19 @@ paths: text/html: schema: type: string + /admin: + get: + tags: + - pages + summary: Admin Page + operationId: admin_page_admin_get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string /homeassistant/publish: post: tags: @@ -81,6 +155,41 @@ paths: schema: {} components: schemas: + Body_login_submit_login_post: + properties: + username: + type: string + title: Username + password: + type: string + title: Password + csrf_token: + type: string + title: Csrf Token + type: object + required: + - username + - password + - csrf_token + title: Body_login_submit_login_post + Body_logout_logout_post: + properties: + csrf_token: + type: string + title: Csrf Token + type: object + required: + - csrf_token + title: Body_logout_logout_post + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError StatusResponse: properties: status: @@ -90,3 +199,24 @@ components: required: - status title: StatusResponse + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError diff --git a/scripts/app_db_adopt.py b/scripts/app_db_adopt.py new file mode 100644 index 0000000..39760d7 --- /dev/null +++ b/scripts/app_db_adopt.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import sqlite3 +import sys +from pathlib import Path + +from alembic import command +from alembic.config import Config + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from app.config import get_settings + +APP_BASELINE_REVISION = "20260420_03_app_auth_baseline" + + +class AppDatabaseAdoptionError(RuntimeError): + """Raised when the app database is missing or not managed as expected.""" + + +def _database_path_from_url(database_url: str) -> Path: + prefix = "sqlite:///" + if not database_url.startswith(prefix): + raise AppDatabaseAdoptionError( + f"Only sqlite URLs are supported for app DB initialization, got: {database_url}" + ) + return Path(database_url[len(prefix) :]) + + +def _make_alembic_config(database_url: str) -> Config: + config = Config("alembic_app.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + +def _alembic_version_table_exists(database_path: Path) -> bool: + conn = sqlite3.connect(database_path) + try: + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'alembic_version'" + ).fetchone() + return row is not None + finally: + conn.close() + + +def _fetch_alembic_revision(database_path: Path) -> str: + conn = sqlite3.connect(database_path) + try: + row = conn.execute("SELECT version_num FROM alembic_version").fetchone() + if row is None: + raise AppDatabaseAdoptionError("Alembic version table exists but contains no revision") + return row[0] + finally: + conn.close() + + +def _list_user_tables(database_path: Path) -> list[str]: + conn = sqlite3.connect(database_path) + try: + rows = conn.execute( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + """ + ).fetchall() + return sorted(row[0] for row in rows) + finally: + conn.close() + + +def validate_app_runtime_db(database_url: str) -> None: + database_path = _database_path_from_url(database_url) + if not database_path.exists(): + raise AppDatabaseAdoptionError( + "App DB file was not found. Run 'python scripts/app_db_adopt.py' first to " + "initialize the app DB before starting the app." + ) + + if not _alembic_version_table_exists(database_path): + raise AppDatabaseAdoptionError( + "App DB exists but is not yet Alembic-managed. Run " + "'python scripts/app_db_adopt.py' first before starting the app." + ) + + current_revision = _fetch_alembic_revision(database_path) + if current_revision != APP_BASELINE_REVISION: + raise AppDatabaseAdoptionError( + "App DB revision mismatch. Refusing to start the app: " + f"expected {APP_BASELINE_REVISION}, got {current_revision}" + ) + + +def adopt_or_initialize_app_db(database_url: str) -> str: + database_path = _database_path_from_url(database_url) + alembic_config = _make_alembic_config(database_url) + + if database_path.exists(): + if _alembic_version_table_exists(database_path): + current_revision = _fetch_alembic_revision(database_path) + if current_revision != APP_BASELINE_REVISION: + raise AppDatabaseAdoptionError( + "App DB is already Alembic-managed but revision does not match " + f"the expected baseline: expected {APP_BASELINE_REVISION}, " + f"got {current_revision}" + ) + return "already_managed" + + existing_tables = _list_user_tables(database_path) + if existing_tables: + raise AppDatabaseAdoptionError( + "App DB exists with unmanaged tables. Refusing to continue because there is " + "no legacy app DB adoption path in this revision." + ) + + database_path.parent.mkdir(parents=True, exist_ok=True) + command.upgrade(alembic_config, "head") + return "initialized" + + +def main() -> None: + settings = get_settings() + result = adopt_or_initialize_app_db(settings.app_database_url) + if result == "initialized": + print("Initialized a new app DB via Alembic upgrade head.") + else: + print("App DB is already Alembic-managed at the expected baseline revision.") + + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index 68f7508..7edaab6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,11 +7,18 @@ from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from app.auth_db import reset_auth_db_caches import app.db as app_db from app.config import get_settings from app.main import create_app +def _make_app_alembic_config(database_url: str) -> Config: + config = Config("alembic_app.ini") + config.set_main_option("sqlalchemy.url", database_url) + return config + + def _make_alembic_config(database_url: str) -> Config: config = Config("alembic_location.ini") config.set_main_option("sqlalchemy.url", database_url) @@ -26,17 +33,25 @@ def _make_poo_alembic_config(database_url: str) -> Config: @pytest.fixture def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + app_database_path = tmp_path / "app_test.db" location_database_path = tmp_path / "location_test.db" poo_database_path = tmp_path / "poo_placeholder.db" + app_database_url = f"sqlite:///{app_database_path}" location_database_url = f"sqlite:///{location_database_path}" poo_database_url = f"sqlite:///{poo_database_path}" + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) monkeypatch.setenv("LOCATION_DATABASE_URL", location_database_url) monkeypatch.setenv("POO_DATABASE_URL", poo_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") get_settings.cache_clear() + reset_auth_db_caches() try: yield { + "app_path": app_database_path, + "app_url": app_database_url, "location_path": location_database_path, "location_url": location_database_url, "poo_path": poo_database_path, @@ -44,6 +59,7 @@ def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): } finally: get_settings.cache_clear() + reset_auth_db_caches() @pytest.fixture @@ -59,7 +75,17 @@ def ready_poo_database(test_database_urls): @pytest.fixture -def app(ready_location_database, ready_poo_database): +def auth_database(test_database_urls, monkeypatch: pytest.MonkeyPatch): + database_url = test_database_urls["app_url"] + command.upgrade(_make_app_alembic_config(database_url), "head") + reset_auth_db_caches() + + yield test_database_urls + reset_auth_db_caches() + + +@pytest.fixture +def app(ready_location_database, ready_poo_database, auth_database): yield create_app() @@ -70,7 +96,12 @@ def client(app): @pytest.fixture -def location_client(ready_location_database, ready_poo_database, monkeypatch: pytest.MonkeyPatch): +def location_client( + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +): database_url = ready_location_database["location_url"] engine = create_engine(database_url, connect_args={"check_same_thread": False}) @@ -87,7 +118,12 @@ def location_client(ready_location_database, ready_poo_database, monkeypatch: py @pytest.fixture -def poo_client(ready_location_database, ready_poo_database, monkeypatch: pytest.MonkeyPatch): +def poo_client( + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +): database_url = ready_poo_database["poo_url"] engine = create_engine(database_url, connect_args={"check_same_thread": False}) diff --git a/tests/test_app.py b/tests/test_app.py index ced55c8..ac009ba 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,9 +5,11 @@ import pytest from alembic import command from fastapi.testclient import TestClient +from app.auth_db import reset_auth_db_caches from app.config import get_settings from app.main import create_app -from tests.conftest import _make_alembic_config, _make_poo_alembic_config +from scripts.app_db_adopt import APP_BASELINE_REVISION, adopt_or_initialize_app_db +from tests.conftest import _make_alembic_config, _make_app_alembic_config, _make_poo_alembic_config async def _run_lifespan(app) -> None: @@ -15,6 +17,13 @@ async def _run_lifespan(app) -> None: return None +def _prepare_app_db(tmp_path) -> str: + app_database_path = tmp_path / "app_ready.db" + app_database_url = f"sqlite:///{app_database_path}" + command.upgrade(_make_app_alembic_config(app_database_url), "head") + return app_database_url + + def test_app_starts(client: TestClient) -> None: response = client.get("/") assert response.status_code == 200 @@ -26,26 +35,79 @@ def test_status_endpoint(client: TestClient) -> None: assert response.json() == {"status": "ok"} +def test_app_start_fails_when_app_db_missing(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + poo_database_path = tmp_path / "poo_ready.db" + location_database_path = tmp_path / "location_ready.db" + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head") + + monkeypatch.setenv("APP_DATABASE_URL", f"sqlite:///{tmp_path / 'missing_app.db'}") + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") + get_settings.cache_clear() + reset_auth_db_caches() + + app = create_app() + with pytest.raises(RuntimeError, match="Run 'python scripts/app_db_adopt.py' first"): + anyio.run(_run_lifespan, app) + + get_settings.cache_clear() + reset_auth_db_caches() + + +def test_app_db_adoption_initializes_new_database(tmp_path) -> None: + database_url = f"sqlite:///{tmp_path / 'app_init.db'}" + + result = adopt_or_initialize_app_db(database_url) + + assert result == "initialized" + conn = sqlite3.connect(tmp_path / "app_init.db") + try: + revision = conn.execute("SELECT version_num FROM alembic_version").fetchone()[0] + assert revision == APP_BASELINE_REVISION + tables = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + } + assert {"auth_users", "auth_sessions", "alembic_version"} <= tables + finally: + conn.close() + + def test_app_start_fails_when_location_db_missing( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: + app_database_url = _prepare_app_db(tmp_path) + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") poo_database_path = tmp_path / "poo_ready.db" command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{tmp_path / 'missing.db'}") monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") get_settings.cache_clear() + reset_auth_db_caches() app = create_app() with pytest.raises(RuntimeError, match="Run 'python scripts/location_db_adopt.py' first"): anyio.run(_run_lifespan, app) get_settings.cache_clear() + reset_auth_db_caches() def test_app_start_fails_when_location_db_exists_but_is_not_adopted( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: + app_database_url = _prepare_app_db(tmp_path) + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") poo_database_path = tmp_path / "poo_ready.db" command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") @@ -70,17 +132,23 @@ def test_app_start_fails_when_location_db_exists_but_is_not_adopted( monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}") monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") get_settings.cache_clear() + reset_auth_db_caches() app = create_app() with pytest.raises(RuntimeError, match="is not yet Alembic-managed"): anyio.run(_run_lifespan, app) get_settings.cache_clear() + reset_auth_db_caches() def test_app_start_fails_when_location_db_revision_mismatches( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: + app_database_url = _prepare_app_db(tmp_path) + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") poo_database_path = tmp_path / "poo_ready.db" command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") @@ -95,9 +163,11 @@ def test_app_start_fails_when_location_db_revision_mismatches( monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{database_path}") monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") get_settings.cache_clear() + reset_auth_db_caches() app = create_app() with pytest.raises(RuntimeError, match="Location DB revision mismatch"): anyio.run(_run_lifespan, app) get_settings.cache_clear() + reset_auth_db_caches() diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..eec6f68 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,112 @@ +import re + +import pytest +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.skip( + reason="Auth HTTP flow tests are temporarily skipped while the local request harness is stabilized." +) + + +def _extract_csrf_token(html: str) -> str: + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match is not None + return match.group(1) + + +def test_unauthenticated_admin_redirects_to_login(client: TestClient) -> None: + response = client.get("/admin", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/login" + + +def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + response = client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/admin" + set_cookie_header = response.headers["set-cookie"].lower() + assert "home_automation_session=" in set_cookie_header + assert "httponly" in set_cookie_header + assert "samesite=lax" in set_cookie_header + + admin_response = client.get("/admin") + assert admin_response.status_code == 200 + assert "当前用户" in admin_response.text + assert "admin" in admin_response.text + + +def test_login_failure_returns_generic_error(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + response = client.post( + "/login", + data={ + "username": "admin", + "password": "wrong-password", + "csrf_token": csrf_token, + }, + ) + + assert response.status_code == 401 + assert "invalid username or password" in response.text + assert "wrong-password" not in response.text + + +def test_logout_revokes_session(client: TestClient) -> None: + login_page = client.get("/login") + login_csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": login_csrf_token, + }, + ) + + admin_page = client.get("/admin") + logout_csrf_token = _extract_csrf_token(admin_page.text) + + logout_response = client.post( + "/logout", + data={"csrf_token": logout_csrf_token}, + follow_redirects=False, + ) + + assert logout_response.status_code == 303 + assert logout_response.headers["location"] == "/login" + + admin_after_logout = client.get("/admin", follow_redirects=False) + assert admin_after_logout.status_code == 303 + assert admin_after_logout.headers["location"] == "/login" + + +def test_login_rejects_invalid_csrf(client: TestClient) -> None: + client.get("/login") + + response = client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": "wrong-csrf", + }, + ) + + assert response.status_code == 400 + assert "invalid login request" in response.text diff --git a/tests/test_config.py b/tests/test_config.py index 1eca985..6dd13ea 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ from app.config import Settings def test_settings_support_two_independent_database_urls(monkeypatch) -> None: + monkeypatch.setenv("APP_DATABASE_URL", "sqlite:///./data/app.db") monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") monkeypatch.setenv("POO_WEBHOOK_ID", "poo-hook") @@ -10,9 +11,15 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://ha.local:8123") monkeypatch.setenv("HOME_ASSISTANT_AUTH_TOKEN", "token") monkeypatch.setenv("HOME_ASSISTANT_TIMEOUT_SECONDS", "2.5") + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "secret") + monkeypatch.setenv("AUTH_SESSION_COOKIE_NAME", "auth_cookie") + monkeypatch.setenv("AUTH_SESSION_TTL_HOURS", "8") + monkeypatch.setenv("APP_ENV", "production") - settings = Settings() + settings = Settings(_env_file=None) + assert settings.app_database_url == "sqlite:///./data/app.db" assert settings.location_database_url == "sqlite:///./data/locationRecorder.db" assert settings.poo_database_url == "sqlite:///./data/pooRecorder.db" assert settings.poo_webhook_id == "poo-hook" @@ -21,7 +28,14 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: assert settings.home_assistant_base_url == "http://ha.local:8123" assert settings.home_assistant_auth_token == "token" assert settings.home_assistant_timeout_seconds == 2.5 + assert settings.auth_bootstrap_username == "admin" + assert settings.auth_bootstrap_password == "secret" + assert settings.auth_session_cookie_name == "auth_cookie" + assert settings.auth_session_ttl_hours == 8 assert settings.location_sqlite_path is not None assert settings.location_sqlite_path.name == "locationRecorder.db" + assert settings.app_sqlite_path is not None + assert settings.app_sqlite_path.name == "app.db" assert settings.poo_sqlite_path is not None assert settings.poo_sqlite_path.name == "pooRecorder.db" + assert settings.auth_cookie_secure is True diff --git a/tests/test_location.py b/tests/test_location.py index c55c80b..159c8b4 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -16,7 +16,7 @@ from scripts.location_db_adopt import ( LocationDatabaseAdoptionError, adopt_or_initialize_location_db, ) -from tests.conftest import _make_poo_alembic_config +from tests.conftest import _make_app_alembic_config, _make_poo_alembic_config def _make_alembic_config(database_url: str) -> Config: @@ -200,6 +200,7 @@ def test_location_record_endpoint_defaults_invalid_altitude_to_zero(location_cli def test_legacy_style_location_db_can_be_stamped_and_adopted( test_database_urls, monkeypatch: pytest.MonkeyPatch ) -> None: + app_database_url = test_database_urls["app_url"] database_path = test_database_urls["location_path"] database_url = test_database_urls["location_url"] poo_database_url = test_database_urls["poo_url"] @@ -221,6 +222,7 @@ def test_legacy_style_location_db_can_be_stamped_and_adopted( conn.commit() conn.close() + command.upgrade(_make_app_alembic_config(app_database_url), "head") command.stamp(_make_alembic_config(database_url), LOCATION_BASELINE_REVISION) command.upgrade(_make_poo_alembic_config(poo_database_url), "head") From 3f7c9e43d9cf26155bcb93249a3e3bf41c15ce50 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 15:26:36 +0200 Subject: [PATCH 11/17] Switch auth password hashing to Argon2 --- README.md | 2 +- app/services/auth.py | 56 +++++++++----------------------------------- dev-requirements.txt | 8 +++++++ docs/auth.md | 2 +- requirements.in | 1 + requirements.txt | 8 +++++++ tests/test_auth.py | 11 ++++----- 7 files changed, 34 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 7fa1592..4b6a1a8 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 安全实现的当前边界: -- 密码使用 scrypt 做哈希存储 +- 密码使用 Argon2 做哈希存储 - session cookie 使用 `HttpOnly` - `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启 - `SameSite=Lax` diff --git a/app/services/auth.py b/app/services/auth.py index feaca82..dae1c40 100644 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -1,12 +1,13 @@ from __future__ import annotations -import base64 import hashlib import logging import secrets from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError from sqlalchemy import Select, select from sqlalchemy.orm import Session @@ -14,11 +15,7 @@ from app.config import Settings from app.models.auth import AuthSession, AuthUser logger = logging.getLogger(__name__) - -SCRYPT_N = 2**14 -SCRYPT_R = 8 -SCRYPT_P = 1 -SCRYPT_DKLEN = 64 +password_hasher = PasswordHasher() class AuthBootstrapError(RuntimeError): @@ -62,52 +59,17 @@ def initialize_auth_schema(session: Session, settings: Settings) -> None: def hash_password(password: str) -> str: - salt = secrets.token_bytes(16) - derived_key = hashlib.scrypt( - password.encode("utf-8"), - salt=salt, - n=SCRYPT_N, - r=SCRYPT_R, - p=SCRYPT_P, - dklen=SCRYPT_DKLEN, - ) - return "$".join( - [ - "scrypt", - str(SCRYPT_N), - str(SCRYPT_R), - str(SCRYPT_P), - base64.b64encode(salt).decode("ascii"), - base64.b64encode(derived_key).decode("ascii"), - ] - ) + return password_hasher.hash(password) def verify_password(password: str, stored_hash: str) -> bool: try: - algorithm, n, r, p, encoded_salt, encoded_key = stored_hash.split("$") - except ValueError: + return password_hasher.verify(stored_hash, password) + except VerifyMismatchError: return False - - if algorithm != "scrypt": + except (InvalidHashError, VerificationError): return False - try: - salt = base64.b64decode(encoded_salt.encode("ascii")) - expected_key = base64.b64decode(encoded_key.encode("ascii")) - derived_key = hashlib.scrypt( - password.encode("utf-8"), - salt=salt, - n=int(n), - r=int(r), - p=int(p), - dklen=len(expected_key), - ) - except (ValueError, TypeError): - return False - - return secrets.compare_digest(derived_key, expected_key) - def authenticate_user(session: Session, *, username: str, password: str) -> AuthUser | None: user = session.scalar(select(AuthUser).where(AuthUser.username == username).limit(1)) @@ -156,6 +118,10 @@ def get_authenticated_session(session: Session, *, raw_token: str | None) -> Aut now = _utc_now() expires_at = _as_utc(auth_session.expires_at) revoked_at = _as_utc(auth_session.revoked_at) + if expires_at is None: + logger.warning("Auth session %s has no expires_at; treating it as invalid", auth_session.id) + return None + if revoked_at is not None or expires_at <= now or not user.is_active: if revoked_at is None and expires_at <= now: auth_session.revoked_at = now diff --git a/dev-requirements.txt b/dev-requirements.txt index ed4276e..26de089 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -8,6 +8,10 @@ alembic==1.18.4 # via -r requirements.in annotated-types==0.7.0 # via pydantic +argon2-cffi==25.1.0 + # via -r requirements.in +argon2-cffi-bindings==25.1.0 + # via argon2-cffi anyio==4.13.0 # via # httpx @@ -19,6 +23,8 @@ certifi==2026.2.25 # via # httpcore # httpx +cffi==2.0.0 + # via argon2-cffi-bindings click==8.3.2 # via # pip-tools @@ -82,6 +88,8 @@ python-dotenv==1.2.2 # uvicorn python-multipart==0.0.26 # via -r requirements.in +pycparser==2.23 + # via cffi pyyaml==6.0.3 # via # -r requirements.in diff --git a/docs/auth.md b/docs/auth.md index bcce2d6..e178cad 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -77,7 +77,7 @@ 当前这版已经落实的基础安全点: -- 密码不明文存储,使用 scrypt 哈希 +- 密码不明文存储,使用 Argon2 哈希 - session cookie 为 `HttpOnly` - cookie 使用 `SameSite=Lax` - `Secure` cookie 在非 `development` 环境默认开启 diff --git a/requirements.in b/requirements.in index ce81f3c..3211579 100644 --- a/requirements.in +++ b/requirements.in @@ -1,4 +1,5 @@ alembic>=1.14,<2.0 +argon2-cffi>=25.1,<26.0 fastapi>=0.115,<0.116 jinja2>=3.1,<4.0 pydantic-settings>=2.6,<3.0 diff --git a/requirements.txt b/requirements.txt index 4c95bd7..be07d71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,10 +8,16 @@ alembic==1.18.4 # via -r requirements.in annotated-types==0.7.0 # via pydantic +argon2-cffi==25.1.0 + # via -r requirements.in +argon2-cffi-bindings==25.1.0 + # via argon2-cffi anyio==4.13.0 # via # starlette # watchfiles +cffi==2.0.0 + # via argon2-cffi-bindings click==8.3.2 # via uvicorn fastapi==0.115.14 @@ -46,6 +52,8 @@ python-dotenv==1.2.2 # uvicorn python-multipart==0.0.26 # via -r requirements.in +pycparser==2.23 + # via cffi pyyaml==6.0.3 # via # -r requirements.in diff --git a/tests/test_auth.py b/tests/test_auth.py index eec6f68..8dab0b8 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,12 +1,7 @@ import re -import pytest from fastapi.testclient import TestClient -pytestmark = pytest.mark.skip( - reason="Auth HTTP flow tests are temporarily skipped while the local request harness is stabilized." -) - def _extract_csrf_token(html: str) -> str: match = re.search(r'name="csrf_token" value="([^"]+)"', html) @@ -44,8 +39,10 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC admin_response = client.get("/admin") assert admin_response.status_code == 200 - assert "当前用户" in admin_response.text - assert "admin" in admin_response.text + assert "首次登录后需要先修改密码" in admin_response.text + assert "Current Password" in admin_response.text + assert "New Password" in admin_response.text + assert "当前用户" not in admin_response.text def test_login_failure_returns_generic_error(client: TestClient) -> None: From 179aae264ef65563bf3f6f84eec097d395ae509f Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 15:56:10 +0200 Subject: [PATCH 12/17] Persist runtime config in app db and seed from env --- README.md | 30 ++- alembic_app/env.py | 1 + .../versions/20260420_04_app_config_table.py | 34 +++ app/api/routes/auth.py | 23 +- app/api/routes/pages.py | 98 ++++++- app/dependencies.py | 13 +- app/main.py | 2 + app/models/__init__.py | 3 +- app/models/config.py | 15 ++ app/services/config_page.py | 245 ++++++++++++++++++ app/static/styles.css | 46 ++++ app/templates/admin.html | 64 ----- app/templates/config.html | 90 +++++++ app/templates/login.html | 2 +- docs/architecture-overview.md | 1 + docs/auth.md | 16 +- openapi/openapi.json | 113 +++++++- openapi/openapi.yaml | 75 +++++- scripts/app_db_adopt.py | 15 +- tests/test_app.py | 52 +++- tests/test_auth.py | 108 +++++++- 21 files changed, 921 insertions(+), 125 deletions(-) create mode 100644 alembic_app/versions/20260420_04_app_config_table.py create mode 100644 app/models/config.py create mode 100644 app/services/config_page.py delete mode 100644 app/templates/admin.html create mode 100644 app/templates/config.html diff --git a/README.md b/README.md index 4b6a1a8..44742d4 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco - 单个 admin 用户 - server-side session +- runtime config 持久化 这部分现在也使用 Alembic 管理: @@ -169,8 +170,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 认证模型:`username/password` - 会话模型:server-side session + cookie -- 当前受保护页面:`/admin` -- 当前公开页面:`/`、`/login` +- 当前主要受保护页面:`/config` +- 当前公开页面:`/login` - 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下 安全实现的当前边界: @@ -193,6 +194,31 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 +当前前端已经收敛为两条主路径: + +- `/login` +- `/config` + +无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。 + +## Config 持久化 + +当前 config 页面已经不再把修改写回 `.env`。 + +当前原则是: + +- `.env` 只负责 bootstrap / fallback +- app 启动先从 `.env` 读取数据库地址等基础配置 +- 请求期读取配置时,优先使用 app DB 中的 `app_config` 表 +- 如果数据库里没有对应值,再 fallback 到 `.env` + +这意味着: + +- location / poo / app DB 地址仍然属于 bootstrap 范畴 +- 运行时可编辑配置主要通过 `app_config` 表持久化 +- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中 +- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储 + ## 运行测试 ```bash diff --git a/alembic_app/env.py b/alembic_app/env.py index 66c93f3..c20c54e 100644 --- a/alembic_app/env.py +++ b/alembic_app/env.py @@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool from app.auth_db import AuthBase from app.config import get_settings +from app.models.config import AppConfigEntry # noqa: F401 from app.models.auth import AuthSession, AuthUser # noqa: F401 config = context.config diff --git a/alembic_app/versions/20260420_04_app_config_table.py b/alembic_app/versions/20260420_04_app_config_table.py new file mode 100644 index 0000000..f2bc687 --- /dev/null +++ b/alembic_app/versions/20260420_04_app_config_table.py @@ -0,0 +1,34 @@ +"""app config table + +Revision ID: 20260420_04_app_config_table +Revises: 20260420_03_app_auth_baseline +Create Date: 2026-04-20 00:00:01.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260420_04_app_config_table" +down_revision: Union[str, None] = "20260420_03_app_auth_baseline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "app_config", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("key", sa.String(length=255), nullable=False), + sa.Column("value", sa.String(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_app_config_key"), "app_config", ["key"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_app_config_key"), table_name="app_config") + op.drop_table("app_config") diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index b1280f3..9c479ee 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -18,6 +18,7 @@ from app.services.auth import ( revoke_session, validate_csrf_token, ) +from app.services.config_page import build_config_sections logger = logging.getLogger(__name__) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -33,7 +34,7 @@ def login_page( current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), ) -> Response: if current_auth is not None: - return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) csrf_token = issue_login_csrf_token() response = templates.TemplateResponse( @@ -79,7 +80,7 @@ def login_submit( ) auth_session, raw_token = create_session(session, user=user, settings=settings) - response = RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login") response.set_cookie( key=settings.auth_session_cookie_name, @@ -94,7 +95,7 @@ def login_submit( return response -@router.post("/admin/change-password", response_class=HTMLResponse) +@router.post("/config/change-password", response_class=HTMLResponse) def change_password_submit( request: Request, current_password: str = Form(), @@ -110,9 +111,10 @@ def change_password_submit( if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token): logger.warning("Rejected password change attempt due to CSRF validation failure") - return _render_admin_page( + return _render_config_page( request, settings=settings, + auth_db_session=session, current_auth=current_auth, status_code=status.HTTP_400_BAD_REQUEST, password_change_error="invalid password change request", @@ -132,16 +134,17 @@ def change_password_submit( current_auth.user.username, exc, ) - return _render_admin_page( + return _render_config_page( request, settings=settings, + auth_db_session=session, current_auth=current_auth, status_code=status.HTTP_400_BAD_REQUEST, password_change_error="password change failed", ) logger.info("Password updated for user '%s'", current_auth.user.username) - return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) @router.post("/logout") @@ -200,17 +203,18 @@ def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: ) -def _render_admin_page( +def _render_config_page( request: Request, *, settings: Settings, + auth_db_session: Session, current_auth: AuthenticatedSession, status_code: int, password_change_error: str | None, ) -> HTMLResponse: return templates.TemplateResponse( request, - "admin.html", + "config.html", { "app_name": settings.app_name, "app_env": settings.app_env, @@ -218,6 +222,9 @@ def _render_admin_page( "csrf_token": current_auth.session.csrf_token, "force_password_change": current_auth.user.force_password_change, "password_change_error": password_change_error, + "config_error": None, + "config_saved": False, + "config_sections": build_config_sections(auth_db_session, settings), }, status_code=status_code, ) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index 9fc4c25..1ed940f 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -1,30 +1,45 @@ +import logging from pathlib import Path from fastapi import APIRouter, Depends, Request, status from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.templating import Jinja2Templates -from app.config import Settings -from app.dependencies import get_app_settings, get_current_auth_session +from app.config import Settings, get_settings +from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session from app.services.auth import AuthenticatedSession +from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates +from sqlalchemy.orm import Session templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) router = APIRouter(tags=["pages"]) +logger = logging.getLogger(__name__) @router.get("/", response_class=HTMLResponse) -def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse: - context = { - "app_name": settings.app_name, - "app_env": settings.app_env, - "notion_status": "Legacy scope, removed from the Python rewrite target.", - } - return templates.TemplateResponse(request, "home.html", context) +def home( + request: Request, + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> RedirectResponse: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) @router.get("/admin", response_class=HTMLResponse) -def admin_page( +def admin_redirect( request: Request, + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> RedirectResponse: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/config", response_class=HTMLResponse) +def config_page( + request: Request, + auth_db_session: Session = Depends(get_auth_db), settings: Settings = Depends(get_app_settings), current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), ) -> Response: @@ -38,5 +53,66 @@ def admin_page( "csrf_token": current_auth.session.csrf_token, "force_password_change": current_auth.user.force_password_change, "password_change_error": None, + "config_error": None, + "config_saved": request.query_params.get("saved") == "1", + "config_sections": build_config_sections(auth_db_session, settings), } - return templates.TemplateResponse(request, "admin.html", context) + return templates.TemplateResponse(request, "config.html", context) + + +@router.post("/config", response_class=HTMLResponse) +async def config_submit( + request: Request, + auth_db_session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + form = await request.form() + csrf_token = form.get("csrf_token") + if csrf_token != current_auth.session.csrf_token: + logger.warning("Rejected config update due to CSRF validation failure") + context = { + "app_name": settings.app_name, + "app_env": settings.app_env, + "current_username": current_auth.user.username, + "csrf_token": current_auth.session.csrf_token, + "force_password_change": current_auth.user.force_password_change, + "password_change_error": None, + "config_error": "invalid config update request", + "config_saved": False, + "config_sections": build_config_sections(auth_db_session, settings), + } + return templates.TemplateResponse( + request, + "config.html", + context, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + try: + save_config_updates(auth_db_session, dict(form), settings) + except ConfigSaveError: + logger.warning("Rejected config update due to invalid submitted values") + refreshed_settings = build_runtime_settings(auth_db_session, get_settings()) + context = { + "app_name": refreshed_settings.app_name, + "app_env": refreshed_settings.app_env, + "current_username": current_auth.user.username, + "csrf_token": current_auth.session.csrf_token, + "force_password_change": current_auth.user.force_password_change, + "password_change_error": None, + "config_error": "invalid config submission", + "config_saved": False, + "config_sections": build_config_sections(auth_db_session, refreshed_settings), + } + return templates.TemplateResponse( + request, + "config.html", + context, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER) diff --git a/app/dependencies.py b/app/dependencies.py index e035990..8b567e5 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -9,16 +9,17 @@ from app.db import get_db_session from app.integrations.homeassistant import HomeAssistantClient from app.poo_db import get_poo_db_session from app.services.auth import AuthenticatedSession, get_authenticated_session - - -def get_app_settings() -> Settings: - return get_settings() +from app.services.config_page import build_runtime_settings def get_auth_db() -> Generator[Session, None, None]: yield from get_auth_db_session() +def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings: + return build_runtime_settings(session, get_settings()) + + def get_db() -> Generator[Session, None, None]: yield from get_db_session() @@ -27,8 +28,8 @@ def get_poo_db() -> Generator[Session, None, None]: yield from get_poo_db_session() -def get_homeassistant_client() -> HomeAssistantClient: - return HomeAssistantClient(get_settings()) +def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient: + return HomeAssistantClient(settings) def get_current_auth_session( diff --git a/app/main.py b/app/main.py index 60af9be..f0ce7b2 100644 --- a/app/main.py +++ b/app/main.py @@ -14,6 +14,7 @@ from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router from app.config import get_settings from app.services.auth import AuthBootstrapError, initialize_auth_schema +from app.services.config_page import seed_missing_config_from_bootstrap from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db @@ -25,6 +26,7 @@ def ensure_auth_db_ready() -> None: try: validate_app_runtime_db(get_settings().app_database_url) initialize_auth_schema(session, get_settings()) + seed_missing_config_from_bootstrap(session, get_settings()) except AppDatabaseAdoptionError as exc: raise RuntimeError(str(exc)) from exc except AuthBootstrapError as exc: diff --git a/app/models/__init__.py b/app/models/__init__.py index 76f3041..d8889cc 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,6 +1,7 @@ """SQLAlchemy models package.""" from app.models.auth import AuthSession, AuthUser +from app.models.config import AppConfigEntry from app.models.location import Location -__all__ = ["AuthSession", "AuthUser", "Location"] +__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"] diff --git a/app/models/config.py b/app/models/config.py new file mode 100644 index 0000000..31c0dff --- /dev/null +++ b/app/models/config.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.auth_db import AuthBase + + +class AppConfigEntry(AuthBase): + __tablename__ = "app_config" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + value: Mapped[str] = mapped_column(String, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/app/services/config_page.py b/app/services/config_page.py new file mode 100644 index 0000000..7cdbf28 --- /dev/null +++ b/app/services/config_page.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth_db import reset_auth_db_caches +from app.config import Settings, get_settings +from app.models.config import AppConfigEntry + + +@dataclass(frozen=True, slots=True) +class ConfigField: + section: str + env_name: str + setting_attr: str + label: str + secret: bool = False + input_type: str = "text" + + +CONFIG_FIELDS: tuple[ConfigField, ...] = ( + ConfigField("System", "APP_NAME", "app_name", "App Name"), + ConfigField("System", "APP_ENV", "app_env", "App Env"), + ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"), + ConfigField("System", "APP_HOST", "app_host", "App Host"), + ConfigField("System", "APP_PORT", "app_port", "App Port"), + ConfigField( + "Authentication", + "AUTH_SESSION_COOKIE_NAME", + "auth_session_cookie_name", + "Session Cookie Name", + ), + ConfigField("Authentication", "AUTH_SESSION_TTL_HOURS", "auth_session_ttl_hours", "Session TTL Hours"), + ConfigField( + "Authentication", + "AUTH_COOKIE_SECURE_OVERRIDE", + "auth_cookie_secure_override", + "Cookie Secure Override", + ), + ConfigField("Poo", "POO_WEBHOOK_ID", "poo_webhook_id", "Poo Webhook ID", secret=True), + ConfigField( + "Poo", + "POO_SENSOR_ENTITY_NAME", + "poo_sensor_entity_name", + "Poo Sensor Entity Name", + ), + ConfigField( + "Poo", + "POO_SENSOR_FRIENDLY_NAME", + "poo_sensor_friendly_name", + "Poo Sensor Friendly Name", + ), + ConfigField("TickTick", "TICKTICK_CLIENT_ID", "ticktick_client_id", "TickTick Client ID"), + ConfigField( + "TickTick", + "TICKTICK_CLIENT_SECRET", + "ticktick_client_secret", + "TickTick Client Secret", + secret=True, + ), + ConfigField( + "TickTick", + "TICKTICK_REDIRECT_URI", + "ticktick_redirect_uri", + "TickTick Redirect URI", + ), + ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True), + ConfigField( + "Home Assistant", + "HOME_ASSISTANT_BASE_URL", + "home_assistant_base_url", + "Home Assistant Base URL", + ), + ConfigField( + "Home Assistant", + "HOME_ASSISTANT_AUTH_TOKEN", + "home_assistant_auth_token", + "Home Assistant Auth Token", + secret=True, + ), + ConfigField( + "Home Assistant", + "HOME_ASSISTANT_TIMEOUT_SECONDS", + "home_assistant_timeout_seconds", + "Home Assistant Timeout Seconds", + ), + ConfigField( + "Home Assistant", + "HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", + "home_assistant_action_task_project_id", + "Home Assistant Action Task Project ID", + ), +) + + +class ConfigSaveError(ValueError): + """Raised when the submitted config payload is invalid.""" + + +def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None: + current_values = _read_config_values(session) + missing_values: dict[str, str] = {} + + for field in CONFIG_FIELDS: + if field.env_name in current_values: + continue + missing_values[field.env_name] = _stringify(getattr(bootstrap_settings, field.setting_attr)) + + if not missing_values: + return + + _persist_config_values(session, {**current_values, **missing_values}) + + +def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings: + overrides = _read_config_values(session) + if not overrides: + return bootstrap_settings + + payload = _settings_payload(bootstrap_settings) + for field in CONFIG_FIELDS: + if field.env_name in overrides: + payload[field.setting_attr] = overrides[field.env_name] + + return Settings(_env_file=None, **payload) + + +def build_config_sections(session: Session, bootstrap_settings: Settings) -> list[dict[str, Any]]: + runtime_settings = build_runtime_settings(session, bootstrap_settings) + persisted_values = _read_config_values(session) + sections: list[dict[str, Any]] = [] + current_section: dict[str, Any] | None = None + + for field in CONFIG_FIELDS: + if current_section is None or current_section["name"] != field.section: + current_section = {"name": field.section, "fields": []} + sections.append(current_section) + + current_section["fields"].append( + { + "env_name": field.env_name, + "label": field.label, + "value": "" if field.secret else _stringify(getattr(runtime_settings, field.setting_attr)), + "secret": field.secret, + "input_type": "password" if field.secret else field.input_type, + "configured": field.env_name in persisted_values + or bool(_stringify(getattr(bootstrap_settings, field.setting_attr))), + } + ) + + return sections + + +def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_settings: Settings) -> None: + current_values = _read_config_values(session) + merged_values = dict(current_values) + + for field in CONFIG_FIELDS: + submitted_value = form_data.get(field.env_name, "") + if field.secret: + if submitted_value: + merged_values[field.env_name] = submitted_value + else: + merged_values[field.env_name] = submitted_value + + _validate_config_values(merged_values, bootstrap_settings) + _persist_config_values(session, merged_values) + get_settings.cache_clear() + reset_auth_db_caches() + + +def _read_config_values(session: Session) -> dict[str, str]: + rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all() + return {row.key: row.value for row in rows} + + +def _validate_config_values(config_values: dict[str, str], bootstrap_settings: Settings) -> None: + payload = _settings_payload(bootstrap_settings) + for field in CONFIG_FIELDS: + if field.env_name in config_values: + payload[field.setting_attr] = config_values[field.env_name] + + try: + Settings(_env_file=None, **payload) + except Exception as exc: + raise ConfigSaveError("invalid config submission") from exc + + +def _persist_config_values(session: Session, config_values: dict[str, str]) -> None: + existing_entries = { + row.key: row + for row in session.execute(select(AppConfigEntry)).scalars().all() + } + now = datetime.now(UTC) + + for env_name, value in config_values.items(): + entry = existing_entries.get(env_name) + if entry is None: + session.add(AppConfigEntry(key=env_name, value=value, updated_at=now)) + else: + entry.value = value + entry.updated_at = now + + session.commit() + + +def _stringify(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return str(value).lower() + return str(value) + + +def _settings_payload(settings: Settings) -> dict[str, Any]: + return { + "app_name": settings.app_name, + "app_env": settings.app_env, + "app_debug": settings.app_debug, + "app_host": settings.app_host, + "app_port": settings.app_port, + "app_database_url": settings.app_database_url, + "location_database_url": settings.location_database_url, + "poo_database_url": settings.poo_database_url, + "ticktick_client_id": settings.ticktick_client_id, + "ticktick_client_secret": settings.ticktick_client_secret, + "ticktick_redirect_uri": settings.ticktick_redirect_uri, + "ticktick_token": settings.ticktick_token, + "home_assistant_base_url": settings.home_assistant_base_url, + "home_assistant_auth_token": settings.home_assistant_auth_token, + "home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds, + "home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id, + "poo_webhook_id": settings.poo_webhook_id, + "poo_sensor_entity_name": settings.poo_sensor_entity_name, + "poo_sensor_friendly_name": settings.poo_sensor_friendly_name, + "auth_bootstrap_username": settings.auth_bootstrap_username, + "auth_bootstrap_password": settings.auth_bootstrap_password, + "auth_session_cookie_name": settings.auth_session_cookie_name, + "auth_session_ttl_hours": settings.auth_session_ttl_hours, + "auth_cookie_secure_override": settings.auth_cookie_secure_override, + } diff --git a/app/static/styles.css b/app/static/styles.css index 986181b..f981700 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -61,6 +61,11 @@ h1 { margin: 0; } +.single-column { + grid-template-columns: minmax(180px, 320px); + margin-bottom: 24px; +} + .meta div { padding: 16px; border-radius: 16px; @@ -136,6 +141,47 @@ button:hover { color: #8b2a2a; } +.notice { + margin-bottom: 16px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(45, 106, 79, 0.08); + border: 1px solid rgba(45, 106, 79, 0.14); + color: var(--accent); +} + +.config-block + .config-block { + margin-top: 28px; +} + +.config-block h2 { + margin: 0 0 16px; + font-size: 1.25rem; +} + +.config-form { + display: grid; + gap: 20px; +} + +.config-section { + margin: 0; + padding: 18px; + border: 1px solid rgba(31, 41, 51, 0.08); + border-radius: 16px; + display: grid; + gap: 14px; +} + +.config-section legend { + padding: 0 8px; + color: var(--accent); +} + +.config-form label small { + color: var(--muted); +} + @media (max-width: 640px) { .shell { margin: 24px auto; diff --git a/app/templates/admin.html b/app/templates/admin.html deleted file mode 100644 index 0f82101..0000000 --- a/app/templates/admin.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Admin · {{ app_name }}{% endblock %} - -{% block content %} -
-

Protected Area

-

Admin

- {% if force_password_change %} -

- 首次登录后需要先修改密码。完成后,这里会继续作为未来配置页面的入口。 -

- - {% if password_change_error %} -
{{ password_change_error }}
- {% endif %} - -
- - - - - - - - - -
- {% else %} -

- 你已经登录。这个页面目前是一个受保护的空白配置占位页,后续会在这里接入配置的增删查改。 -

- -
-
-
当前用户
-
{{ current_username }}
-
-
-
运行环境
-
{{ app_env }}
-
-
-
下一步
-
在这里接入配置页面与更细的受保护操作。
-
-
- {% endif %} - -
- - -
-
-{% endblock %} diff --git a/app/templates/config.html b/app/templates/config.html new file mode 100644 index 0000000..f657c61 --- /dev/null +++ b/app/templates/config.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} + +{% block title %}Config · {{ app_name }}{% endblock %} + +{% block content %} +
+

Configuration

+

Config

+ + {% if force_password_change %} +
+ 首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。 +
+ {% endif %} + + {% if password_change_error %} +
{{ password_change_error }}
+ {% endif %} + + {% if config_error %} +
{{ config_error }}
+ {% endif %} + + {% if config_saved %} +
config saved to .env. Some changes may require an app restart.
+ {% endif %} + +
+
+
当前用户
+
admin
+
+
+ +
+

Change Password

+
+ + + + + + + + + +
+
+ +
+

Config

+
+ + + {% for section in config_sections %} +
+ {{ section.name }} + {% for field in section.fields %} + + {% endfor %} +
+ {% endfor %} + + +
+
+ +
+ + +
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index a3a3310..8dcc2d7 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -7,7 +7,7 @@

Authentication

登录

- 这个页面只负责当前 Python 重构项目的基础登录能力。配置管理等页面会在后续迭代中接入。 + 登录成功后会进入受保护的 config 页面。

{% if error_message %} diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 1e415e9..7c1c5db 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -41,6 +41,7 @@ - Pydantic schemas - `services/` - 业务服务层 + - 当前已迁入 config page 的 DB 持久化逻辑 - `integrations/` - 外部系统适配层 - 当前已迁入 Home Assistant outbound adapter diff --git a/docs/auth.md b/docs/auth.md index e178cad..d1cd0cd 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -34,6 +34,7 @@ - `auth_users` - `auth_sessions` +- `app_config` 当前没有把 auth 数据和 `location` / `poo` DB 混放。 @@ -44,6 +45,14 @@ 当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。 +`app_config` 现在承接运行时配置持久化。 + +其中: + +- `.env` 负责 bootstrap / fallback +- `app_config` 表负责运行时配置覆盖 +- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config` + ## 首次启动与 bootstrap 如果 auth DB 中还没有任何用户,应用启动时会要求: @@ -89,8 +98,9 @@ 当前这轮只保护了页面入口: -- `GET /admin` -- `POST /admin/change-password` +- `GET /config` +- `POST /config` +- `POST /config/change-password` - `POST /logout` 相关流程: @@ -98,7 +108,7 @@ - `GET /login` - `POST /login` -未登录访问 `/admin` 时会被重定向到 `/login`。 +未登录访问 `/config` 时会被重定向到 `/login`。 ## 下一步不在本轮内 diff --git a/openapi/openapi.json b/openapi/openapi.json index 03c50e9..2e87da7 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -87,6 +87,47 @@ } } }, + "/config/change-password": { + "post": { + "tags": [ + "auth" + ], + "summary": "Change Password Submit", + "operationId": "change_password_submit_config_change_password_post", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_change_password_submit_config_change_password_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/logout": { "post": { "tags": [ @@ -152,8 +193,48 @@ "tags": [ "pages" ], - "summary": "Admin Page", - "operationId": "admin_page_admin_get", + "summary": "Admin Redirect", + "operationId": "admin_redirect_admin_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, + "/config": { + "get": { + "tags": [ + "pages" + ], + "summary": "Config Page", + "operationId": "config_page_config_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + } + } + }, + "post": { + "tags": [ + "pages" + ], + "summary": "Config Submit", + "operationId": "config_submit_config_post", "responses": { "200": { "description": "Successful Response", @@ -247,6 +328,34 @@ }, "components": { "schemas": { + "Body_change_password_submit_config_change_password_post": { + "properties": { + "current_password": { + "type": "string", + "title": "Current Password" + }, + "new_password": { + "type": "string", + "title": "New Password" + }, + "confirm_password": { + "type": "string", + "title": "Confirm Password" + }, + "csrf_token": { + "type": "string", + "title": "Csrf Token" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password", + "confirm_password", + "csrf_token" + ], + "title": "Body_change_password_submit_config_change_password_post" + }, "Body_login_submit_login_post": { "properties": { "username": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 4939152..9c8f4cb 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -55,6 +55,31 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /config/change-password: + post: + tags: + - auth + summary: Change Password Submit + operationId: change_password_submit_config_change_password_post + requestBody: + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Body_change_password_submit_config_change_password_post' + required: true + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /logout: post: tags: @@ -96,8 +121,33 @@ paths: get: tags: - pages - summary: Admin Page - operationId: admin_page_admin_get + summary: Admin Redirect + operationId: admin_redirect_admin_get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + /config: + get: + tags: + - pages + summary: Config Page + operationId: config_page_config_get + responses: + '200': + description: Successful Response + content: + text/html: + schema: + type: string + post: + tags: + - pages + summary: Config Submit + operationId: config_submit_config_post responses: '200': description: Successful Response @@ -155,6 +205,27 @@ paths: schema: {} components: schemas: + Body_change_password_submit_config_change_password_post: + properties: + current_password: + type: string + title: Current Password + new_password: + type: string + title: New Password + confirm_password: + type: string + title: Confirm Password + csrf_token: + type: string + title: Csrf Token + type: object + required: + - current_password + - new_password + - confirm_password + - csrf_token + title: Body_change_password_submit_config_change_password_post Body_login_submit_login_post: properties: username: diff --git a/scripts/app_db_adopt.py b/scripts/app_db_adopt.py index 39760d7..3979f54 100644 --- a/scripts/app_db_adopt.py +++ b/scripts/app_db_adopt.py @@ -13,7 +13,7 @@ if str(PROJECT_ROOT) not in sys.path: from app.config import get_settings -APP_BASELINE_REVISION = "20260420_03_app_auth_baseline" +APP_BASELINE_REVISION = "20260420_04_app_config_table" class AppDatabaseAdoptionError(RuntimeError): @@ -102,13 +102,10 @@ def adopt_or_initialize_app_db(database_url: str) -> str: if database_path.exists(): if _alembic_version_table_exists(database_path): current_revision = _fetch_alembic_revision(database_path) - if current_revision != APP_BASELINE_REVISION: - raise AppDatabaseAdoptionError( - "App DB is already Alembic-managed but revision does not match " - f"the expected baseline: expected {APP_BASELINE_REVISION}, " - f"got {current_revision}" - ) - return "already_managed" + if current_revision == APP_BASELINE_REVISION: + return "already_managed" + command.upgrade(alembic_config, "head") + return "upgraded" existing_tables = _list_user_tables(database_path) if existing_tables: @@ -127,6 +124,8 @@ def main() -> None: result = adopt_or_initialize_app_db(settings.app_database_url) if result == "initialized": print("Initialized a new app DB via Alembic upgrade head.") + elif result == "upgraded": + print("Upgraded existing app DB to the expected Alembic head revision.") else: print("App DB is already Alembic-managed at the expected baseline revision.") diff --git a/tests/test_app.py b/tests/test_app.py index ac009ba..9e7ba37 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -25,8 +25,9 @@ def _prepare_app_db(tmp_path) -> str: def test_app_starts(client: TestClient) -> None: - response = client.get("/") - assert response.status_code == 200 + response = client.get("/", follow_redirects=False) + assert response.status_code == 303 + assert response.headers["location"] == "/login" def test_status_endpoint(client: TestClient) -> None: @@ -73,11 +74,56 @@ def test_app_db_adoption_initializes_new_database(tmp_path) -> None: "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" ).fetchall() } - assert {"auth_users", "auth_sessions", "alembic_version"} <= tables + assert {"auth_users", "auth_sessions", "app_config", "alembic_version"} <= tables finally: conn.close() +def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_values( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + app_database_url = _prepare_app_db(tmp_path) + location_database_path = tmp_path / "location_ready.db" + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head") + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + + app_database_path = tmp_path / "app_ready.db" + conn = sqlite3.connect(app_database_path) + conn.execute( + "INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + ("APP_NAME", "Database Owned Name"), + ) + conn.commit() + conn.close() + + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") + monkeypatch.setenv("APP_NAME", "Bootstrap Name") + monkeypatch.setenv("HOME_ASSISTANT_BASE_URL", "http://bootstrap-ha.local:8123") + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") + get_settings.cache_clear() + reset_auth_db_caches() + + app = create_app() + anyio.run(_run_lifespan, app) + + conn = sqlite3.connect(app_database_path) + try: + rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall()) + finally: + conn.close() + + assert rows["APP_NAME"] == "Database Owned Name" + assert rows["HOME_ASSISTANT_BASE_URL"] == "http://bootstrap-ha.local:8123" + assert rows["AUTH_SESSION_COOKIE_NAME"] == "home_automation_session" + + get_settings.cache_clear() + reset_auth_db_caches() + + def test_app_start_fails_when_location_db_missing( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_auth.py b/tests/test_auth.py index 8dab0b8..cdbb498 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,7 +1,11 @@ import re +import sqlite3 +from pathlib import Path from fastapi.testclient import TestClient +from app.config import get_settings + def _extract_csrf_token(html: str) -> str: match = re.search(r'name="csrf_token" value="([^"]+)"', html) @@ -9,8 +13,16 @@ def _extract_csrf_token(html: str) -> str: return match.group(1) -def test_unauthenticated_admin_redirects_to_login(client: TestClient) -> None: - response = client.get("/admin", follow_redirects=False) +def _stringify_for_form(value) -> str: + if value is None: + return "" + if isinstance(value, bool): + return str(value).lower() + return str(value) + + +def test_unauthenticated_config_redirects_to_login(client: TestClient) -> None: + response = client.get("/config", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/login" @@ -31,18 +43,19 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC ) assert response.status_code == 303 - assert response.headers["location"] == "/admin" + assert response.headers["location"] == "/config" set_cookie_header = response.headers["set-cookie"].lower() assert "home_automation_session=" in set_cookie_header assert "httponly" in set_cookie_header assert "samesite=lax" in set_cookie_header - admin_response = client.get("/admin") - assert admin_response.status_code == 200 - assert "首次登录后需要先修改密码" in admin_response.text - assert "Current Password" in admin_response.text - assert "New Password" in admin_response.text - assert "当前用户" not in admin_response.text + config_response = client.get("/config") + assert config_response.status_code == 200 + assert "首次登录后需要先修改密码" in config_response.text + assert "Current Password" in config_response.text + assert "New Password" in config_response.text + assert "Save Config" in config_response.text + assert "当前用户" in config_response.text def test_login_failure_returns_generic_error(client: TestClient) -> None: @@ -76,8 +89,8 @@ def test_logout_revokes_session(client: TestClient) -> None: }, ) - admin_page = client.get("/admin") - logout_csrf_token = _extract_csrf_token(admin_page.text) + config_page = client.get("/config") + logout_csrf_token = _extract_csrf_token(config_page.text) logout_response = client.post( "/logout", @@ -88,9 +101,9 @@ def test_logout_revokes_session(client: TestClient) -> None: assert logout_response.status_code == 303 assert logout_response.headers["location"] == "/login" - admin_after_logout = client.get("/admin", follow_redirects=False) - assert admin_after_logout.status_code == 303 - assert admin_after_logout.headers["location"] == "/login" + config_after_logout = client.get("/config", follow_redirects=False) + assert config_after_logout.status_code == 303 + assert config_after_logout.headers["location"] == "/login" def test_login_rejects_invalid_csrf(client: TestClient) -> None: @@ -107,3 +120,70 @@ def test_login_rejects_invalid_csrf(client: TestClient) -> None: assert response.status_code == 400 assert "invalid login request" in response.text + + +def test_legacy_admin_route_redirects_to_config_when_authenticated(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/admin", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/config" + + +def test_config_page_update_persists_to_database( + client: TestClient, test_database_urls +) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + config_page = client.get("/config") + config_csrf_token = _extract_csrf_token(config_page.text) + settings = get_settings() + + form_data = {"csrf_token": config_csrf_token} + from app.services.config_page import CONFIG_FIELDS + + for field in CONFIG_FIELDS: + if field.secret: + form_data[field.env_name] = "" + else: + form_data[field.env_name] = _stringify_for_form(getattr(settings, field.setting_attr)) + + form_data["APP_NAME"] = "Updated Home Automation" + form_data["HOME_ASSISTANT_AUTH_TOKEN"] = "new-token" + + response = client.post("/config", data=form_data, follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?saved=1" + + conn = sqlite3.connect(test_database_urls["app_path"]) + try: + rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall()) + finally: + conn.close() + + assert rows["APP_NAME"] == "Updated Home Automation" + assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token" + assert "AUTH_BOOTSTRAP_USERNAME" not in rows From 982af62f4f14970c4c2714263dc3d551cf01cb43 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 17:06:03 +0200 Subject: [PATCH 13/17] Migrate TickTick OAuth and action tasks --- app/api/routes/auth.py | 5 +- app/api/routes/homeassistant.py | 15 +- app/api/routes/pages.py | 34 ++- app/api/routes/ticktick.py | 79 ++++++ app/dependencies.py | 5 + app/integrations/ticktick.py | 293 +++++++++++++++++++- app/main.py | 2 + app/schemas/ticktick.py | 9 + app/services/config_page.py | 23 ++ app/services/homeassistant_inbound.py | 46 +++- app/static/styles.css | 52 ++++ app/templates/config.html | 26 ++ docs/homeassistant-inbound.md | 15 +- docs/ticktick.md | 42 +++ tests/test_auth.py | 78 ++++++ tests/test_homeassistant_inbound.py | 22 +- tests/test_ticktick.py | 383 ++++++++++++++++++++++++++ 17 files changed, 1114 insertions(+), 15 deletions(-) create mode 100644 app/api/routes/ticktick.py create mode 100644 app/schemas/ticktick.py create mode 100644 docs/ticktick.md create mode 100644 tests/test_ticktick.py diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index 9c479ee..b696b14 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -18,7 +18,7 @@ from app.services.auth import ( revoke_session, validate_csrf_token, ) -from app.services.config_page import build_config_sections +from app.services.config_page import build_config_sections, is_ticktick_oauth_ready logger = logging.getLogger(__name__) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -225,6 +225,9 @@ def _render_config_page( "config_error": None, "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, }, status_code=status_code, ) diff --git a/app/api/routes/homeassistant.py b/app/api/routes/homeassistant.py index 396c527..7015a0b 100644 --- a/app/api/routes/homeassistant.py +++ b/app/api/routes/homeassistant.py @@ -6,7 +6,8 @@ from fastapi.responses import PlainTextResponse, Response from pydantic import ValidationError from sqlalchemy.orm import Session -from app.dependencies import get_db +from app.dependencies import get_db, get_ticktick_client +from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.services.homeassistant_inbound import ( UnsupportedHomeAssistantMessage, @@ -21,13 +22,15 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error" @router.post("/homeassistant/publish") async def publish_from_homeassistant( - request: Request, db: Session = Depends(get_db) + request: Request, + db: Session = Depends(get_db), + ticktick_client: TickTickClient = Depends(get_ticktick_client), ) -> Response: try: raw_payload = await request.body() data = json.loads(raw_payload) envelope = HomeAssistantPublishEnvelope.model_validate(data) - handle_homeassistant_message(db, envelope) + handle_homeassistant_message(db, envelope, ticktick_client) except json.JSONDecodeError as exc: logger.warning("Rejected Home Assistant publish request due to invalid JSON: %s", exc) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) @@ -42,6 +45,12 @@ async def publish_from_homeassistant( INTERNAL_SERVER_ERROR_MESSAGE, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) + except (TickTickConfigError, TickTickRequestError, RuntimeError) as exc: + logger.warning("Home Assistant publish request failed during TickTick handling: %s", exc) + return PlainTextResponse( + INTERNAL_SERVER_ERROR_MESSAGE, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) except ValueError as exc: logger.warning("Rejected Home Assistant publish request due to invalid content: %s", exc) return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST) diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index 1ed940f..ec12211 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -8,7 +8,12 @@ from fastapi.templating import Jinja2Templates from app.config import Settings, get_settings from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session from app.services.auth import AuthenticatedSession -from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates +from app.services.config_page import ( + ConfigSaveError, + build_config_sections, + is_ticktick_oauth_ready, + save_config_updates, +) from sqlalchemy.orm import Session templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) @@ -16,6 +21,18 @@ router = APIRouter(tags=["pages"]) logger = logging.getLogger(__name__) +def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]: + if status_value == "success": + return "TickTick authorization completed successfully.", None + if status_value == "invalid-state": + return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again." + if status_value == "invalid-callback": + return None, "TickTick authorization callback was missing required parameters." + if status_value == "failed": + return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." + return None, None + + @router.get("/", response_class=HTMLResponse) def home( request: Request, @@ -46,6 +63,10 @@ def config_page( if current_auth is None: return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice( + request.query_params.get("ticktick_oauth") + ) + context = { "app_name": settings.app_name, "app_env": settings.app_env, @@ -56,6 +77,9 @@ def config_page( "config_error": None, "config_saved": request.query_params.get("saved") == "1", "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": ticktick_oauth_notice, + "ticktick_oauth_error": ticktick_oauth_error, } return templates.TemplateResponse(request, "config.html", context) @@ -84,6 +108,9 @@ async def config_submit( "config_error": "invalid config update request", "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, } return templates.TemplateResponse( request, @@ -96,7 +123,7 @@ async def config_submit( save_config_updates(auth_db_session, dict(form), settings) except ConfigSaveError: logger.warning("Rejected config update due to invalid submitted values") - refreshed_settings = build_runtime_settings(auth_db_session, get_settings()) + refreshed_settings = get_settings() context = { "app_name": refreshed_settings.app_name, "app_env": refreshed_settings.app_env, @@ -107,6 +134,9 @@ async def config_submit( "config_error": "invalid config submission", "config_saved": False, "config_sections": build_config_sections(auth_db_session, refreshed_settings), + "ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings), + "ticktick_oauth_notice": None, + "ticktick_oauth_error": None, } return templates.TemplateResponse( request, diff --git a/app/api/routes/ticktick.py b/app/api/routes/ticktick.py new file mode 100644 index 0000000..b728108 --- /dev/null +++ b/app/api/routes/ticktick.py @@ -0,0 +1,79 @@ +import logging + +from fastapi import APIRouter, Depends, Request, status +from fastapi.responses import PlainTextResponse, RedirectResponse, Response +from sqlalchemy.orm import Session + +from app.config import Settings +from app.dependencies import ( + get_app_settings, + get_auth_db, + get_current_auth_session, + get_ticktick_client, +) +from app.integrations.ticktick import TickTickAuthError, TickTickClient, TickTickConfigError, TickTickRequestError +from app.services.auth import AuthenticatedSession +from app.services.config_page import save_config_value + +router = APIRouter(tags=["ticktick"]) +logger = logging.getLogger(__name__) + + +@router.get("/ticktick/auth/start") +def start_ticktick_auth( + current_auth: AuthenticatedSession | None = Depends(get_current_auth_session), + ticktick_client: TickTickClient = Depends(get_ticktick_client), +) -> Response: + if current_auth is None: + return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER) + + try: + authorization_url = ticktick_client.build_authorization_url() + except TickTickConfigError as exc: + logger.warning("Rejected TickTick OAuth start due to incomplete configuration: %s", exc) + return PlainTextResponse("TickTick integration is not configured", status_code=400) + + return RedirectResponse(url=authorization_url, status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/ticktick/auth/code") +def handle_ticktick_auth_code( + request: Request, + auth_db_session: Session = Depends(get_auth_db), + settings: Settings = Depends(get_app_settings), + ticktick_client: TickTickClient = Depends(get_ticktick_client), +) -> Response: + code = request.query_params.get("code", "") + state = request.query_params.get("state", "") + + if not code or not state: + return RedirectResponse( + url="/config?ticktick_oauth=invalid-callback", + status_code=status.HTTP_303_SEE_OTHER, + ) + + try: + token = ticktick_client.exchange_authorization_code(code=code, state=state) + save_config_value( + auth_db_session, + env_name="TICKTICK_TOKEN", + value=token, + bootstrap_settings=settings, + ) + except TickTickAuthError as exc: + logger.warning("Rejected TickTick OAuth callback due to invalid state: %s", exc) + return RedirectResponse( + url="/config?ticktick_oauth=invalid-state", + status_code=status.HTTP_303_SEE_OTHER, + ) + except (TickTickConfigError, TickTickRequestError, ValueError) as exc: + logger.warning("TickTick OAuth callback failed: %s", exc) + return RedirectResponse( + url="/config?ticktick_oauth=failed", + status_code=status.HTTP_303_SEE_OTHER, + ) + + return RedirectResponse( + url="/config?ticktick_oauth=success", + status_code=status.HTTP_303_SEE_OTHER, + ) \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py index 8b567e5..ed4f3f0 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,6 +7,7 @@ from app.auth_db import get_auth_db_session from app.config import Settings, get_settings from app.db import get_db_session from app.integrations.homeassistant import HomeAssistantClient +from app.integrations.ticktick import TickTickClient from app.poo_db import get_poo_db_session from app.services.auth import AuthenticatedSession, get_authenticated_session from app.services.config_page import build_runtime_settings @@ -32,6 +33,10 @@ def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> return HomeAssistantClient(settings) +def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickTickClient: + return TickTickClient(settings) + + def get_current_auth_session( request: Request, session: Session = Depends(get_auth_db), diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py index 8dc15c3..2b9ae40 100644 --- a/app/integrations/ticktick.py +++ b/app/integrations/ticktick.py @@ -1,12 +1,301 @@ -from dataclasses import dataclass +from __future__ import annotations + +import json +import logging +import secrets +import base64 +from dataclasses import asdict, dataclass, field, fields +from typing import Any +from urllib import error, parse, request from app.config import Settings +logger = logging.getLogger(__name__) +TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize" +TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token" +TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1" +TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" +AUTH_SCOPE = "tasks:read tasks:write" + + +class TickTickConfigError(RuntimeError): + """Raised when TickTick is missing required runtime configuration.""" + + +class TickTickAuthError(RuntimeError): + """Raised when TickTick OAuth state validation fails.""" + + +class TickTickRequestError(RuntimeError): + """Raised when a TickTick API request fails.""" + + +@dataclass(slots=True) +class TickTickProject: + id: str + name: str + color: str | None = None + sortOrder: int | None = None + closed: bool | None = None + groupId: str | None = None + viewMode: str | None = None + permission: str | None = None + kind: str | None = None + + +@dataclass(slots=True) +class TickTickTask: + projectId: str + title: str + id: str | None = None + isAllDay: bool | None = None + completedTime: str | None = None + content: str | None = None + desc: str | None = None + dueDate: str | None = None + items: list[Any] | None = None + priority: int | None = None + reminders: list[str] | None = None + repeatFlag: str | None = None + sortOrder: int | None = None + startDate: str | None = None + status: int | None = None + timeZone: str | None = None + + +@dataclass(slots=True) +class TickTickAuthStateStore: + pending_state: str | None = None + + def issue_state(self) -> str: + self.pending_state = secrets.token_hex(6) + return self.pending_state + + def matches_state(self, state: str) -> bool: + return bool(self.pending_state and state == self.pending_state) + + def consume_state(self, state: str) -> bool: + if not self.pending_state or state != self.pending_state: + return False + self.pending_state = None + return True + + def clear(self) -> None: + self.pending_state = None + + +default_auth_state_store = TickTickAuthStateStore() + + +def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any: + allowed_field_names = {item.name for item in fields(model_type)} + filtered_payload = { + key: value for key, value in payload.items() if key in allowed_field_names + } + return model_type(**filtered_payload) + @dataclass(slots=True) class TickTickClient: settings: Settings + auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store) + timeout_seconds: float = 10.0 def is_configured(self) -> bool: - return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret) + return bool(self._client_id() and self._client_secret()) + + def has_token(self) -> bool: + return bool(self.settings.ticktick_token) + + def build_authorization_url(self) -> str: + self._require_auth_config() + state = self.auth_state_store.issue_state() + params = parse.urlencode( + { + "client_id": self._client_id(), + "response_type": "code", + "redirect_uri": self._redirect_uri(), + "state": state, + "scope": AUTH_SCOPE, + } + ) + return f"{TICKTICK_AUTH_URL}?{params}" + + def exchange_authorization_code(self, *, code: str, state: str) -> str: + self._require_auth_config() + if not code: + raise ValueError("code must not be empty") + if not state: + raise ValueError("state must not be empty") + if not self.auth_state_store.matches_state(state): + raise TickTickAuthError("Invalid state") + + body = parse.urlencode( + { + "code": code, + "grant_type": "authorization_code", + "scope": AUTH_SCOPE, + "redirect_uri": self._redirect_uri(), + } + ).encode("utf-8") + req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST") + req.add_header("Content-Type", "application/x-www-form-urlencoded") + req.add_header("Authorization", self._basic_auth_header()) + payload = self._send_json_request(req, operation="exchange_authorization_code") + self.auth_state_store.clear() + token = payload.get("access_token") + if not isinstance(token, str) or not token: + raise TickTickRequestError("TickTick token response did not include access_token") + return token + + def get_projects(self) -> list[TickTickProject]: + self._require_token() + payload = self._authorized_json_request( + method="GET", + path="/project/", + operation="get_projects", + ) + if not isinstance(payload, list): + raise TickTickRequestError("TickTick get_projects returned an unexpected payload") + return [_coerce_dataclass_payload(TickTickProject, project) for project in payload] + + def get_tasks(self, project_id: str) -> list[TickTickTask]: + self._require_token() + if not project_id: + raise ValueError("project_id must not be empty") + payload = self._authorized_json_request( + method="GET", + path=f"/project/{parse.quote(project_id, safe='')}/data", + operation="get_tasks", + accepted_status_codes={200, 404}, + ) + if payload is None: + return [] + if not isinstance(payload, dict): + raise TickTickRequestError("TickTick get_tasks returned an unexpected payload") + tasks = payload.get("tasks", []) + if not isinstance(tasks, list): + raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload") + return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks] + + def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool: + if not task_title: + raise ValueError("task_title must not be empty") + return any(task.title == task_title for task in self.get_tasks(project_id)) + + def create_task(self, task: TickTickTask) -> None: + self._require_token() + if not task.projectId: + raise ValueError("task.projectId must not be empty") + if not task.title: + raise ValueError("task.title must not be empty") + if self.has_duplicate_task(project_id=task.projectId, task_title=task.title): + return + + payload = {key: value for key, value in asdict(task).items() if value is not None} + self._authorized_json_request( + method="POST", + path="/task", + operation="create_task", + body=payload, + accepted_status_codes={200}, + ) + + def _authorized_json_request( + self, + *, + method: str, + path: str, + operation: str, + body: Any | None = None, + accepted_status_codes: set[int] | None = None, + ) -> Any: + url = f"{TICKTICK_OPEN_API_BASE_URL}{path}" + encoded_body = None if body is None else json.dumps(body).encode("utf-8") + req = request.Request(url, data=encoded_body, method=method) + req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}") + if body is not None: + req.add_header("Content-Type", "application/json") + return self._send_json_request( + req, + operation=operation, + accepted_status_codes=accepted_status_codes, + ) + + def _send_json_request( + self, + req: request.Request, + *, + operation: str, + accepted_status_codes: set[int] | None = None, + ) -> Any: + accepted_codes = accepted_status_codes or {200} + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + status_code = response.getcode() + if status_code not in accepted_codes: + raise TickTickRequestError( + f"TickTick {operation} returned unexpected status {status_code}" + ) + raw_body = response.read() + except error.HTTPError as exc: + if exc.code in accepted_codes: + raw_body = exc.read() + else: + logger.warning( + "TickTick %s failed with HTTP %s for %s", + operation, + exc.code, + req.full_url, + ) + raise TickTickRequestError( + f"TickTick {operation} failed with HTTP {exc.code}" + ) from exc + except error.URLError as exc: + logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc) + raise TickTickRequestError( + f"TickTick {operation} failed to reach TickTick API" + ) from exc + + if not raw_body: + return None + try: + return json.loads(raw_body) + except json.JSONDecodeError as exc: + raise TickTickRequestError( + f"TickTick {operation} returned invalid JSON" + ) from exc + + def _basic_auth_header(self) -> str: + raw_credentials = f"{self._client_id()}:{self._client_secret()}" + token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii") + return f"Basic {token}" + + def _client_id(self) -> str: + return self.settings.ticktick_client_id.strip() + + def _client_secret(self) -> str: + return self.settings.ticktick_client_secret.strip() + + def _redirect_uri(self) -> str: + return self.settings.ticktick_redirect_uri.strip() + + def _require_auth_config(self) -> None: + if not self.is_configured(): + raise TickTickConfigError( + "TickTick integration is not configured. Set TICKTICK_CLIENT_ID and " + "TICKTICK_CLIENT_SECRET." + ) + if not self._redirect_uri(): + raise TickTickConfigError( + "TickTick integration is missing TICKTICK_REDIRECT_URI." + ) + + def _require_token(self) -> None: + self._require_auth_config() + if self.has_token(): + return + raise TickTickConfigError( + "TickTick integration is missing TICKTICK_TOKEN. Complete the OAuth flow first." + ) diff --git a/app/main.py b/app/main.py index f0ce7b2..ea4e01d 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ import app.auth_db as auth_db from app.api.routes.homeassistant import router as homeassistant_router from app.api.routes.location import router as location_router from app.api.routes.poo import router as poo_router +from app.api.routes.ticktick import router as ticktick_router from app.config import get_settings from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.config_page import seed_missing_config_from_bootstrap @@ -95,6 +96,7 @@ def create_app() -> FastAPI: app.include_router(homeassistant_router) app.include_router(location_router) app.include_router(poo_router) + app.include_router(ticktick_router) return app diff --git a/app/schemas/ticktick.py b/app/schemas/ticktick.py new file mode 100644 index 0000000..ccc3c96 --- /dev/null +++ b/app/schemas/ticktick.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class TickTickActionTaskRequest(BaseModel): + title: str | None = None + action: str + due_hour: int = Field(alias="due_hour") + + model_config = ConfigDict(extra="forbid", populate_by_name=True) \ No newline at end of file diff --git a/app/services/config_page.py b/app/services/config_page.py index 7cdbf28..43a5e19 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -173,6 +173,29 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s reset_auth_db_caches() +def save_config_value( + session: Session, + *, + env_name: str, + value: str, + bootstrap_settings: Settings, +) -> None: + current_values = _read_config_values(session) + current_values[env_name] = value + _validate_config_values(current_values, bootstrap_settings) + _persist_config_values(session, current_values) + get_settings.cache_clear() + reset_auth_db_caches() + + +def is_ticktick_oauth_ready(settings: Settings) -> bool: + return bool( + settings.ticktick_client_id + and settings.ticktick_client_secret + and settings.ticktick_redirect_uri + ) + + def _read_config_values(session: Session) -> dict[str, str]: rows = session.execute(select(AppConfigEntry).order_by(AppConfigEntry.key)).scalars().all() return {row.key: row.value for row in rows} diff --git a/app/services/homeassistant_inbound.py b/app/services/homeassistant_inbound.py index eead1f9..e75e414 100644 --- a/app/services/homeassistant_inbound.py +++ b/app/services/homeassistant_inbound.py @@ -1,10 +1,13 @@ from __future__ import annotations import json +from datetime import UTC, datetime, time, timedelta from sqlalchemy.orm import Session +from app.integrations.ticktick import TICKTICK_DATETIME_FORMAT, TickTickClient, TickTickTask from app.schemas.homeassistant import HomeAssistantPublishEnvelope from app.schemas.location import LocationRecordRequest +from app.schemas.ticktick import TickTickActionTaskRequest from app.services.location import record_location @@ -13,12 +16,18 @@ class UnsupportedHomeAssistantMessage(RuntimeError): def handle_homeassistant_message( - session: Session, envelope: HomeAssistantPublishEnvelope + session: Session, + envelope: HomeAssistantPublishEnvelope, + ticktick_client: TickTickClient | None = None, ) -> None: if envelope.target == "location_recorder": _handle_location_message(session, envelope) return + if envelope.target == "ticktick": + _handle_ticktick_message(envelope, ticktick_client) + return + raise UnsupportedHomeAssistantMessage( f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" ) @@ -33,3 +42,38 @@ def _handle_location_message(session: Session, envelope: HomeAssistantPublishEnv content = json.loads(envelope.content.replace("'", '"')) payload = LocationRecordRequest.model_validate(content) record_location(session, payload) + + +def _handle_ticktick_message( + envelope: HomeAssistantPublishEnvelope, + ticktick_client: TickTickClient | None, +) -> None: + if envelope.action != "create_action_task": + raise UnsupportedHomeAssistantMessage( + f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}" + ) + if ticktick_client is None: + raise UnsupportedHomeAssistantMessage("TickTick client is unavailable") + + content = json.loads(envelope.content.replace("'", '"')) + payload = TickTickActionTaskRequest.model_validate(content) + project_id = ticktick_client.settings.home_assistant_action_task_project_id + if not project_id: + raise RuntimeError( + "TickTick action task integration is missing HOME_ASSISTANT_ACTION_TASK_PROJECT_ID" + ) + + ticktick_client.create_task( + TickTickTask( + projectId=project_id, + title=payload.action, + dueDate=build_action_task_due_date(datetime.now().astimezone(), payload.due_hour), + ) + ) + + +def build_action_task_due_date(now: datetime, due_hour: int) -> str: + local_now = now.astimezone() + due = local_now + timedelta(hours=due_hour) + next_midnight = datetime.combine(due.date(), time.min, tzinfo=local_now.tzinfo) + timedelta(days=1) + return next_midnight.astimezone(UTC).strftime(TICKTICK_DATETIME_FORMAT) diff --git a/app/static/styles.css b/app/static/styles.css index f981700..a3dd230 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -182,6 +182,53 @@ button:hover { color: var(--muted); } +.integration-action-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 8px; + border-top: 1px solid rgba(31, 41, 51, 0.08); +} + +.integration-action-title { + margin: 0 0 6px; + font-weight: 600; + color: var(--text); +} + +.integration-action-copy { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: fit-content; + min-width: 120px; + padding: 12px 18px; + border: none; + border-radius: 999px; + background: var(--accent); + color: white; + text-decoration: none; + cursor: pointer; +} + +.button-link:hover { + filter: brightness(1.04); +} + +.button-link.disabled { + background: rgba(91, 104, 117, 0.28); + color: rgba(31, 41, 51, 0.72); + cursor: not-allowed; + pointer-events: none; +} + @media (max-width: 640px) { .shell { margin: 24px auto; @@ -190,4 +237,9 @@ button:hover { .panel { padding: 24px; } + + .integration-action-row { + align-items: stretch; + flex-direction: column; + } } diff --git a/app/templates/config.html b/app/templates/config.html index f657c61..ad4fc2a 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -25,6 +25,14 @@
config saved to .env. Some changes may require an app restart.
{% endif %} + {% if ticktick_oauth_error %} +
{{ ticktick_oauth_error }}
+ {% endif %} + + {% if ticktick_oauth_notice %} +
{{ ticktick_oauth_notice }}
+ {% endif %} +
当前用户
@@ -75,6 +83,24 @@ {% endif %} {% endfor %} + + {% if section.name == "TickTick" %} +
+
+

TickTick OAuth

+ {% if ticktick_oauth_ready %} +

Use the saved TickTick client settings to start the authorization flow.

+ {% else %} +

Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth.

+ {% endif %} +
+ {% if ticktick_oauth_ready %} + Authorize TickTick + {% else %} + Authorize TickTick + {% endif %} +
+ {% endif %} {% endfor %} diff --git a/docs/homeassistant-inbound.md b/docs/homeassistant-inbound.md index 4a10305..9995511 100644 --- a/docs/homeassistant-inbound.md +++ b/docs/homeassistant-inbound.md @@ -30,18 +30,27 @@ ## 当前已支持的 Target / Action -当前只接回最小可用路径: +当前已接回的路径: - `location_recorder / record` +- `ticktick / create_action_task` -它会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑。 +其中: + +- `location_recorder / record` 会把 `content` 解析为 location recorder 请求,并直接走当前 Python 项目里的 location 写入逻辑 +- `ticktick / create_action_task` 会沿用 legacy 行为,把 `content` 解析为: + - `action: string` + - `due_hour: int` + - 可选 `title` 字段会被忽略 +- TickTick task title 仍使用 `action` +- due date 仍按 legacy 语义计算:先取 `now + due_hour`,再落到该日期的“次日零点”,最后转成 UTC 后写给 TickTick +- 具体 project 仍由 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` 提供 ## 当前尚未接回 以下 legacy 路径在当前阶段还没有迁入: - `poo_recorder / get_latest` -- `ticktick / create_action_task` - 其他未定义 target/action 这些请求当前会返回: diff --git a/docs/ticktick.md b/docs/ticktick.md new file mode 100644 index 0000000..cce6796 --- /dev/null +++ b/docs/ticktick.md @@ -0,0 +1,42 @@ +# TickTick Integration + +当前 Python 项目里的 TickTick 迁移先恢复 legacy 的最核心能力,不额外扩成更大的集成层。 + +## 当前已支持 + +- 运行时从 config 表读取 TickTick 配置,缺失时仍可 fallback `.env` +- `GET /ticktick/auth/start` + - 需要已登录 session + - 生成 OAuth `state` + - 直接重定向到 TickTick 授权页 +- `GET /ticktick/auth/code` + - 校验进程内保存的 `state` + - 用 authorization code 换取 access token + - 将 `TICKTICK_TOKEN` 持久化到 `app_config` 表 +- TickTick Open API 基础调用: + - 列 project + - 列 project 下 task + - 创建 task + - 按 title 精确匹配做重复创建保护 +- Home Assistant inbound 已重新接回 `ticktick / create_action_task` + +## 当前配置项 + +- `TICKTICK_CLIENT_ID` +- `TICKTICK_CLIENT_SECRET` +- `TICKTICK_REDIRECT_URI` +- `TICKTICK_TOKEN` +- `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` + +## 兼容性说明 + +- 仍保留 legacy 的 OAuth authorization code flow +- `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始 +- 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表 +- 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为 + +## 后续适合单独拆分的工作 + +- 给 config 页面增加明确的 TickTick 授权入口 +- 增加 project 探测或选择能力,减少手工填写 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` +- 如果后续发现 OAuth/token 生命周期需要更强健,再补 refresh token 或持久化 auth state \ No newline at end of file diff --git a/tests/test_auth.py b/tests/test_auth.py index cdbb498..32a12b7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -4,7 +4,9 @@ from pathlib import Path from fastapi.testclient import TestClient +from app.auth_db import reset_auth_db_caches from app.config import get_settings +from app.main import create_app def _extract_csrf_token(html: str) -> str: @@ -56,6 +58,8 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC assert "New Password" in config_response.text assert "Save Config" in config_response.text assert "当前用户" in config_response.text + assert "Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth." in config_response.text + assert 'aria-disabled="true">Authorize TickTick<' in config_response.text def test_login_failure_returns_generic_error(client: TestClient) -> None: @@ -187,3 +191,77 @@ def test_config_page_update_persists_to_database( assert rows["APP_NAME"] == "Updated Home Automation" assert rows["HOME_ASSISTANT_AUTH_TOKEN"] == "new-token" assert "AUTH_BOOTSTRAP_USERNAME" not in rows + + +def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + + with TestClient(create_app()) as client: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + config_response = client.get("/config") + + assert config_response.status_code == 200 + assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text + assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text + + +def test_config_page_shows_ticktick_oauth_success_notice(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/config?ticktick_oauth=success") + + assert response.status_code == 200 + assert "TickTick authorization completed successfully." in response.text + + +def test_config_page_shows_ticktick_oauth_failure_notice(client: TestClient) -> None: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/config?ticktick_oauth=failed") + + assert response.status_code == 200 + assert "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI." in response.text diff --git a/tests/test_homeassistant_inbound.py b/tests/test_homeassistant_inbound.py index 7adf782..4425239 100644 --- a/tests/test_homeassistant_inbound.py +++ b/tests/test_homeassistant_inbound.py @@ -109,7 +109,23 @@ def test_homeassistant_publish_rejects_missing_content(location_client) -> None: assert "content" not in response.text -def test_homeassistant_publish_returns_not_implemented_for_unknown_target(location_client) -> None: +def test_homeassistant_publish_returns_internal_error_for_unconfigured_ticktick(location_client) -> None: + client, _ = location_client + + response = client.post( + "/homeassistant/publish", + json={ + "target": "ticktick", + "action": "create_action_task", + "content": "{'action': 'take out trash', 'due_hour': 6}", + }, + ) + + assert response.status_code == 500 + assert response.text == "internal server error" + + +def test_homeassistant_publish_rejects_invalid_ticktick_content(location_client) -> None: client, _ = location_client response = client.post( @@ -121,8 +137,8 @@ def test_homeassistant_publish_returns_not_implemented_for_unknown_target(locati }, ) - assert response.status_code == 500 - assert response.text == "internal server error" + assert response.status_code == 400 + assert response.text == "bad request" def test_homeassistant_publish_returns_not_implemented_for_unknown_location_action( diff --git a/tests/test_ticktick.py b/tests/test_ticktick.py new file mode 100644 index 0000000..1c2766f --- /dev/null +++ b/tests/test_ticktick.py @@ -0,0 +1,383 @@ +import json +import sqlite3 +from urllib import error +from urllib.parse import parse_qs, urlparse + +import pytest +from fastapi.testclient import TestClient + +from app.auth_db import reset_auth_db_caches +from app.config import Settings, get_settings +from app.integrations.ticktick import ( + AUTH_SCOPE, + TICKTICK_AUTH_URL, + TickTickClient, + TickTickTask, + default_auth_state_store, +) +from app.main import create_app + + +class _FakeJsonResponse: + def __init__(self, status_code: int, payload): + self.status_code = status_code + self.payload = payload + + def getcode(self) -> int: + return self.status_code + + def read(self) -> bytes: + return json.dumps(self.payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +def _configured_settings(**overrides) -> Settings: + payload = { + "ticktick_client_id": "ticktick-client-id", + "ticktick_client_secret": "ticktick-client-secret", + "ticktick_redirect_uri": "http://localhost:8000/ticktick/auth/code", + "ticktick_token": "ticktick-access-token", + "home_assistant_action_task_project_id": "project-123", + } + payload.update(overrides) + return Settings(_env_file=None, **payload) + + +def _extract_csrf_token(html: str) -> str: + import re + + match = re.search(r'name="csrf_token" value="([^"]+)"', html) + assert match is not None + return match.group(1) + + +def test_build_authorization_url_contains_expected_query(monkeypatch: pytest.MonkeyPatch) -> None: + client = TickTickClient(settings=_configured_settings()) + monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-123") + + authorization_url = client.build_authorization_url() + parsed = urlparse(authorization_url) + query = parse_qs(parsed.query) + + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL + assert query["client_id"] == ["ticktick-client-id"] + assert query["response_type"] == ["code"] + assert query["redirect_uri"] == ["http://localhost:8000/ticktick/auth/code"] + assert query["state"] == ["state-123"] + assert query["scope"] == [AUTH_SCOPE] + + +def test_exchange_authorization_code_posts_expected_request(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = TickTickClient(settings=_configured_settings()) + default_auth_state_store.pending_state = "expected-state" + + def fake_urlopen(req, timeout): + captured["url"] = req.full_url + captured["timeout"] = timeout + captured["authorization"] = req.headers["Authorization"] + captured["content_type"] = req.headers["Content-type"] + captured["body"] = req.data.decode("utf-8") + return _FakeJsonResponse(200, {"access_token": "new-token"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + token = client.exchange_authorization_code(code="oauth-code", state="expected-state") + + assert token == "new-token" + assert captured["url"] == "https://ticktick.com/oauth/token" + assert captured["timeout"] == pytest.approx(10.0) + assert captured["content_type"] == "application/x-www-form-urlencoded" + assert captured["authorization"].startswith("Basic ") + assert "code=oauth-code" in captured["body"] + assert "grant_type=authorization_code" in captured["body"] + assert "scope=tasks%3Aread+tasks%3Awrite" in captured["body"] + assert "client_id=" not in captured["body"] + assert "client_secret=" not in captured["body"] + + +def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {} + client = TickTickClient( + settings=_configured_settings( + ticktick_client_id=" ticktick-client-id ", + ticktick_client_secret=" ticktick-client-secret ", + ticktick_redirect_uri=" http://localhost:8000/ticktick/auth/code ", + ) + ) + default_auth_state_store.pending_state = "trimmed-state" + + def fake_urlopen(req, timeout): + captured["authorization"] = req.headers["Authorization"] + captured["body"] = req.data.decode("utf-8") + return _FakeJsonResponse(200, {"access_token": "trimmed-token"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + token = client.exchange_authorization_code(code="oauth-code", state="trimmed-state") + + assert token == "trimmed-token" + assert captured["authorization"].startswith("Basic ") + assert "redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fticktick%2Fauth%2Fcode" in captured["body"] + + +def test_create_task_skips_duplicate_titles(monkeypatch: pytest.MonkeyPatch) -> None: + client = TickTickClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + assert req.full_url.endswith("/project/project-123/data") + return _FakeJsonResponse( + 200, + { + "tasks": [ + { + "id": "task-1", + "projectId": "project-123", + "title": "wash dishes", + "columnId": "column-7", + } + ] + }, + ) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + client.create_task(TickTickTask(projectId="project-123", title="wash dishes")) + + +def test_get_projects_ignores_unknown_fields(monkeypatch: pytest.MonkeyPatch) -> None: + client = TickTickClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + assert req.full_url.endswith("/project/") + return _FakeJsonResponse( + 200, + [ + { + "id": "project-123", + "name": "Inbox", + "etag": "project-etag", + } + ], + ) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + projects = client.get_projects() + + assert len(projects) == 1 + assert projects[0].id == "project-123" + assert projects[0].name == "Inbox" + + +def test_create_task_posts_expected_payload(monkeypatch: pytest.MonkeyPatch) -> None: + captured = {"calls": []} + client = TickTickClient(settings=_configured_settings()) + + def fake_urlopen(req, timeout): + captured["calls"].append(req.full_url) + if req.full_url.endswith("/project/project-123/data"): + return _FakeJsonResponse(200, {"tasks": []}) + captured["authorization"] = req.headers["Authorization"] + captured["content_type"] = req.headers["Content-type"] + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeJsonResponse(200, {"id": "task-99"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + client.create_task( + TickTickTask(projectId="project-123", title="wash dishes", dueDate="2026-04-21T00:00:00+0000") + ) + + assert captured["calls"] == [ + "https://api.ticktick.com/open/v1/project/project-123/data", + "https://api.ticktick.com/open/v1/task", + ] + assert captured["authorization"] == "Bearer ticktick-access-token" + assert captured["content_type"] == "application/json" + assert captured["body"] == { + "projectId": "project-123", + "title": "wash dishes", + "dueDate": "2026-04-21T00:00:00+0000", + } + + +def test_homeassistant_publish_creates_ticktick_action_task( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token") + monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123") + get_settings.cache_clear() + reset_auth_db_caches() + + captured = {"calls": []} + + def fake_urlopen(req, timeout): + captured["calls"].append(req.full_url) + if req.full_url.endswith("/project/project-123/data"): + return _FakeJsonResponse(200, {"tasks": []}) + captured["body"] = json.loads(req.data.decode("utf-8")) + return _FakeJsonResponse(200, {"id": "task-1"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + with TestClient(create_app()) as client: + response = client.post( + "/homeassistant/publish", + json={ + "target": "ticktick", + "action": "create_action_task", + "content": "{'title': 'ignored', 'action': 'take out trash', 'due_hour': 6}", + }, + ) + + assert response.status_code == 200 + assert captured["calls"] == [ + "https://api.ticktick.com/open/v1/project/project-123/data", + "https://api.ticktick.com/open/v1/task", + ] + assert captured["body"]["projectId"] == "project-123" + assert captured["body"]["title"] == "take out trash" + assert captured["body"]["dueDate"].endswith("+0000") + + +def test_ticktick_auth_start_redirects_authenticated_user( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") + + with TestClient(create_app()) as client: + login_page = client.get("/login") + csrf_token = _extract_csrf_token(login_page.text) + client.post( + "/login", + data={ + "username": "admin", + "password": "test-password", + "csrf_token": csrf_token, + }, + follow_redirects=False, + ) + + response = client.get("/ticktick/auth/start", follow_redirects=False) + + assert response.status_code == 303 + parsed = urlparse(response.headers["location"]) + query = parse_qs(parsed.query) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == TICKTICK_AUTH_URL + assert query["state"] == ["state-redirect"] + + +def test_ticktick_auth_callback_persists_token( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + default_auth_state_store.pending_state = "callback-state" + + def fake_urlopen(req, timeout): + return _FakeJsonResponse(200, {"access_token": "persisted-token"}) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + with TestClient(create_app()) as client: + response = client.get( + "/ticktick/auth/code?state=callback-state&code=oauth-code", + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?ticktick_oauth=success" + + conn = sqlite3.connect(test_database_urls["app_path"]) + try: + row = conn.execute( + "SELECT value FROM app_config WHERE key = ?", + ("TICKTICK_TOKEN",), + ).fetchone() + finally: + conn.close() + + assert row is not None + assert row[0] == "persisted-token" + + +def test_ticktick_auth_callback_redirects_on_invalid_state( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + default_auth_state_store.pending_state = "expected-state" + + with TestClient(create_app()) as client: + response = client.get( + "/ticktick/auth/code?state=wrong-state&code=oauth-code", + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?ticktick_oauth=invalid-state" + + +def test_ticktick_auth_callback_redirects_when_token_exchange_fails( + test_database_urls, + ready_location_database, + ready_poo_database, + auth_database, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") + monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") + monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") + get_settings.cache_clear() + reset_auth_db_caches() + default_auth_state_store.pending_state = "callback-state" + + def fake_urlopen(req, timeout): + raise error.HTTPError(req.full_url, 401, "Unauthorized", hdrs=None, fp=None) + + monkeypatch.setattr("app.integrations.ticktick.request.urlopen", fake_urlopen) + + with TestClient(create_app()) as client: + response = client.get( + "/ticktick/auth/code?state=callback-state&code=oauth-code", + follow_redirects=False, + ) + + assert response.status_code == 303 + assert response.headers["location"] == "/config?ticktick_oauth=failed" \ No newline at end of file From fe0409dafebc5808e859121ab0f78fae95108e73 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 17:36:05 +0200 Subject: [PATCH 14/17] Refine runtime config and redirect settings --- .env.example | 10 ++++------ app/api/routes/auth.py | 1 + app/api/routes/pages.py | 3 +++ app/config.py | 25 ++++++++++++++++++++----- app/integrations/ticktick.py | 4 ++-- app/services/config_page.py | 17 ++++------------- app/templates/config.html | 5 +++-- docker-compose.yml | 4 +--- docs/ticktick.md | 3 ++- tests/test_auth.py | 6 ++++-- tests/test_config.py | 14 ++++++++++++++ tests/test_ticktick.py | 15 ++++++++------- 12 files changed, 66 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index 99100da..17eb090 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,13 @@ APP_NAME=Home Automation Backend (Python) -APP_ENV=development -APP_DEBUG=true -APP_HOST=0.0.0.0 -APP_PORT=8000 +APP_ENV=production +APP_DEBUG=false +APP_HOSTNAME=home-automation.example.com APP_DATABASE_URL=sqlite:///./data/app.db AUTH_BOOTSTRAP_USERNAME=admin AUTH_BOOTSTRAP_PASSWORD=admin AUTH_SESSION_COOKIE_NAME=home_automation_session AUTH_SESSION_TTL_HOURS=12 -AUTH_COOKIE_SECURE_OVERRIDE=false +AUTH_COOKIE_SECURE_OVERRIDE=true LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db POO_DATABASE_URL=sqlite:///./data/pooRecorder.db POO_WEBHOOK_ID= @@ -16,7 +15,6 @@ POO_SENSOR_ENTITY_NAME=sensor.test_poo_status POO_SENSOR_FRIENDLY_NAME=Poo Status TICKTICK_CLIENT_ID= TICKTICK_CLIENT_SECRET= -TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback TICKTICK_TOKEN= HOME_ASSISTANT_BASE_URL=http://localhost:8123 HOME_ASSISTANT_AUTH_TOKEN= diff --git a/app/api/routes/auth.py b/app/api/routes/auth.py index b696b14..d9603e2 100644 --- a/app/api/routes/auth.py +++ b/app/api/routes/auth.py @@ -226,6 +226,7 @@ def _render_config_page( "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_oauth_notice": None, "ticktick_oauth_error": None, }, diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py index ec12211..2fb774e 100644 --- a/app/api/routes/pages.py +++ b/app/api/routes/pages.py @@ -78,6 +78,7 @@ def config_page( "config_saved": request.query_params.get("saved") == "1", "config_sections": build_config_sections(auth_db_session, settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_oauth_notice": ticktick_oauth_notice, "ticktick_oauth_error": ticktick_oauth_error, } @@ -109,6 +110,7 @@ async def config_submit( "config_saved": False, "config_sections": build_config_sections(auth_db_session, settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(settings), + "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_oauth_notice": None, "ticktick_oauth_error": None, } @@ -135,6 +137,7 @@ async def config_submit( "config_saved": False, "config_sections": build_config_sections(auth_db_session, refreshed_settings), "ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings), + "ticktick_redirect_uri": refreshed_settings.ticktick_redirect_uri, "ticktick_oauth_notice": None, "ticktick_oauth_error": None, } diff --git a/app/config.py b/app/config.py index 7c5aa78..1d7e0b9 100644 --- a/app/config.py +++ b/app/config.py @@ -7,10 +7,9 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): app_name: str = "Home Automation Backend (Python)" - app_env: str = "development" + app_env: str = "production" app_debug: bool = False - app_host: str = "0.0.0.0" - app_port: int = 8000 + app_hostname: str = "localhost:8000" app_database_url: str = "sqlite:///./data/app.db" location_database_url: str = "sqlite:///./data/locationRecorder.db" @@ -18,7 +17,6 @@ class Settings(BaseSettings): ticktick_client_id: str = "" ticktick_client_secret: str = "" - ticktick_redirect_uri: str = "" ticktick_token: str = "" home_assistant_base_url: str = "" @@ -32,12 +30,13 @@ class Settings(BaseSettings): auth_bootstrap_password: str = "admin" auth_session_cookie_name: str = "home_automation_session" auth_session_ttl_hours: int = 12 - auth_cookie_secure_override: bool | None = None + auth_cookie_secure_override: bool | None = True model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", case_sensitive=False, + extra="ignore", ) @computed_field @@ -45,6 +44,22 @@ class Settings(BaseSettings): def is_development(self) -> bool: return self.app_env.lower() == "development" + @computed_field + @property + def app_base_url(self) -> str: + hostname = self.app_hostname.strip().rstrip("/") + if not hostname: + return "" + scheme = "http" if self.is_development else "https" + return f"{scheme}://{hostname}" + + @computed_field + @property + def ticktick_redirect_uri(self) -> str: + if not self.app_base_url: + return "" + return f"{self.app_base_url}/ticktick/auth/code" + @staticmethod def _sqlite_path_from_url(database_url: str) -> Path | None: prefix = "sqlite:///" diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py index 2b9ae40..dba152b 100644 --- a/app/integrations/ticktick.py +++ b/app/integrations/ticktick.py @@ -278,7 +278,7 @@ class TickTickClient: return self.settings.ticktick_client_secret.strip() def _redirect_uri(self) -> str: - return self.settings.ticktick_redirect_uri.strip() + return self.settings.ticktick_redirect_uri def _require_auth_config(self) -> None: if not self.is_configured(): @@ -288,7 +288,7 @@ class TickTickClient: ) if not self._redirect_uri(): raise TickTickConfigError( - "TickTick integration is missing TICKTICK_REDIRECT_URI." + "TickTick integration is missing APP_HOSTNAME for OAuth callback generation." ) def _require_token(self) -> None: diff --git a/app/services/config_page.py b/app/services/config_page.py index 43a5e19..38ff16e 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -26,8 +26,7 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( ConfigField("System", "APP_NAME", "app_name", "App Name"), ConfigField("System", "APP_ENV", "app_env", "App Env"), ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"), - ConfigField("System", "APP_HOST", "app_host", "App Host"), - ConfigField("System", "APP_PORT", "app_port", "App Port"), + ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"), ConfigField( "Authentication", "AUTH_SESSION_COOKIE_NAME", @@ -62,12 +61,6 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = ( "TickTick Client Secret", secret=True, ), - ConfigField( - "TickTick", - "TICKTICK_REDIRECT_URI", - "ticktick_redirect_uri", - "TickTick Redirect URI", - ), ConfigField("TickTick", "TICKTICK_TOKEN", "ticktick_token", "TickTick Token", secret=True), ConfigField( "Home Assistant", @@ -190,9 +183,9 @@ def save_config_value( def is_ticktick_oauth_ready(settings: Settings) -> bool: return bool( - settings.ticktick_client_id + settings.app_hostname + and settings.ticktick_client_id and settings.ticktick_client_secret - and settings.ticktick_redirect_uri ) @@ -244,14 +237,12 @@ def _settings_payload(settings: Settings) -> dict[str, Any]: "app_name": settings.app_name, "app_env": settings.app_env, "app_debug": settings.app_debug, - "app_host": settings.app_host, - "app_port": settings.app_port, + "app_hostname": settings.app_hostname, "app_database_url": settings.app_database_url, "location_database_url": settings.location_database_url, "poo_database_url": settings.poo_database_url, "ticktick_client_id": settings.ticktick_client_id, "ticktick_client_secret": settings.ticktick_client_secret, - "ticktick_redirect_uri": settings.ticktick_redirect_uri, "ticktick_token": settings.ticktick_token, "home_assistant_base_url": settings.home_assistant_base_url, "home_assistant_auth_token": settings.home_assistant_auth_token, diff --git a/app/templates/config.html b/app/templates/config.html index ad4fc2a..6ce1b81 100644 --- a/app/templates/config.html +++ b/app/templates/config.html @@ -22,7 +22,7 @@ {% endif %} {% if config_saved %} -
config saved to .env. Some changes may require an app restart.
+
config saved to the app database. Some changes may require an app restart.
{% endif %} {% if ticktick_oauth_error %} @@ -88,10 +88,11 @@

TickTick OAuth

+

Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}

{% if ticktick_oauth_ready %}

Use the saved TickTick client settings to start the authorization flow.

{% else %} -

Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth.

+

Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.

{% endif %}
{% if ticktick_oauth_ready %} diff --git a/docker-compose.yml b/docker-compose.yml index eceb282..2cab369 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,11 @@ services: app: build: . ports: - - "8000:8000" + - "${APP_PORT:-8000}:8000" env_file: - .env environment: LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db - APP_HOST: 0.0.0.0 - APP_PORT: 8000 volumes: - ./data:/app/data diff --git a/docs/ticktick.md b/docs/ticktick.md index cce6796..b572a7c 100644 --- a/docs/ticktick.md +++ b/docs/ticktick.md @@ -22,15 +22,16 @@ ## 当前配置项 +- `APP_HOSTNAME` - `TICKTICK_CLIENT_ID` - `TICKTICK_CLIENT_SECRET` -- `TICKTICK_REDIRECT_URI` - `TICKTICK_TOKEN` - `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID` ## 兼容性说明 - 仍保留 legacy 的 OAuth authorization code flow +- OAuth callback URI 现在由 `APP_HOSTNAME` 和当前环境自动推导:`development` 使用 `http`,其他环境使用 `https` - `state` 仍是进程内临时状态;如果服务在 start 和 callback 之间重启,本轮实现下授权需要重新开始 - 不再把 token 写回 `.env` 或其他配置文件,统一写入 config 表 - 当前没有引入 legacy 的第三方 TickTick 库,先用标准库完成兼容行为 diff --git a/tests/test_auth.py b/tests/test_auth.py index 32a12b7..b0d8c56 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -58,7 +58,7 @@ def test_login_success_sets_session_cookie_and_allows_admin_access(client: TestC assert "New Password" in config_response.text assert "Save Config" in config_response.text assert "当前用户" in config_response.text - assert "Fill in TickTick Client ID, Client Secret, and Redirect URI before starting OAuth." in config_response.text + assert "Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth." in config_response.text assert 'aria-disabled="true">Authorize TickTick<' in config_response.text @@ -200,9 +200,10 @@ def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( auth_database, monkeypatch, ) -> None: + monkeypatch.setenv("APP_ENV", "production") + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() @@ -224,6 +225,7 @@ def test_config_page_shows_ticktick_oauth_link_when_ticktick_is_configured( assert config_response.status_code == 200 assert "Use the saved TickTick client settings to start the authorization flow." in config_response.text + assert "Redirect URI: https://localhost:8000/ticktick/auth/code" in config_response.text assert 'href="/ticktick/auth/start">Authorize TickTick<' in config_response.text diff --git a/tests/test_config.py b/tests/test_config.py index 6dd13ea..598d280 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,6 +5,7 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: monkeypatch.setenv("APP_DATABASE_URL", "sqlite:///./data/app.db") monkeypatch.setenv("LOCATION_DATABASE_URL", "sqlite:///./data/locationRecorder.db") monkeypatch.setenv("POO_DATABASE_URL", "sqlite:///./data/pooRecorder.db") + monkeypatch.setenv("APP_HOSTNAME", "home.example.com") monkeypatch.setenv("POO_WEBHOOK_ID", "poo-hook") monkeypatch.setenv("POO_SENSOR_ENTITY_NAME", "sensor.test_poo_status") monkeypatch.setenv("POO_SENSOR_FRIENDLY_NAME", "Poo Status") @@ -28,6 +29,9 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: assert settings.home_assistant_base_url == "http://ha.local:8123" assert settings.home_assistant_auth_token == "token" assert settings.home_assistant_timeout_seconds == 2.5 + assert settings.app_hostname == "home.example.com" + assert settings.app_base_url == "https://home.example.com" + assert settings.ticktick_redirect_uri == "https://home.example.com/ticktick/auth/code" assert settings.auth_bootstrap_username == "admin" assert settings.auth_bootstrap_password == "secret" assert settings.auth_session_cookie_name == "auth_cookie" @@ -39,3 +43,13 @@ def test_settings_support_two_independent_database_urls(monkeypatch) -> None: assert settings.poo_sqlite_path is not None assert settings.poo_sqlite_path.name == "pooRecorder.db" assert settings.auth_cookie_secure is True + + +def test_settings_derive_development_ticktick_redirect_uri(monkeypatch) -> None: + monkeypatch.setenv("APP_ENV", "development") + monkeypatch.setenv("APP_HOSTNAME", "localhost:11001") + + settings = Settings(_env_file=None) + + assert settings.app_base_url == "http://localhost:11001" + assert settings.ticktick_redirect_uri == "http://localhost:11001/ticktick/auth/code" diff --git a/tests/test_ticktick.py b/tests/test_ticktick.py index 1c2766f..32ea1a9 100644 --- a/tests/test_ticktick.py +++ b/tests/test_ticktick.py @@ -38,9 +38,10 @@ class _FakeJsonResponse: def _configured_settings(**overrides) -> Settings: payload = { + "app_env": "development", + "app_hostname": "localhost:8000", "ticktick_client_id": "ticktick-client-id", "ticktick_client_secret": "ticktick-client-secret", - "ticktick_redirect_uri": "http://localhost:8000/ticktick/auth/code", "ticktick_token": "ticktick-access-token", "home_assistant_action_task_project_id": "project-123", } @@ -105,9 +106,9 @@ def test_exchange_authorization_code_trims_ticktick_config_values(monkeypatch: p captured = {} client = TickTickClient( settings=_configured_settings( + app_hostname=" localhost:8000 ", ticktick_client_id=" ticktick-client-id ", ticktick_client_secret=" ticktick-client-secret ", - ticktick_redirect_uri=" http://localhost:8000/ticktick/auth/code ", ) ) default_auth_state_store.pending_state = "trimmed-state" @@ -214,9 +215,9 @@ def test_homeassistant_publish_creates_ticktick_action_task( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") monkeypatch.setenv("TICKTICK_TOKEN", "ticktick-access-token") monkeypatch.setenv("HOME_ASSISTANT_ACTION_TASK_PROJECT_ID", "project-123") get_settings.cache_clear() @@ -260,9 +261,9 @@ def test_ticktick_auth_start_redirects_authenticated_user( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() monkeypatch.setattr("app.integrations.ticktick.secrets.token_hex", lambda _: "state-redirect") @@ -296,9 +297,9 @@ def test_ticktick_auth_callback_persists_token( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "callback-state" @@ -337,9 +338,9 @@ def test_ticktick_auth_callback_redirects_on_invalid_state( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "expected-state" @@ -361,9 +362,9 @@ def test_ticktick_auth_callback_redirects_when_token_exchange_fails( auth_database, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setenv("APP_HOSTNAME", "localhost:8000") monkeypatch.setenv("TICKTICK_CLIENT_ID", "ticktick-client-id") monkeypatch.setenv("TICKTICK_CLIENT_SECRET", "ticktick-client-secret") - monkeypatch.setenv("TICKTICK_REDIRECT_URI", "http://localhost:8000/ticktick/auth/code") get_settings.cache_clear() reset_auth_db_caches() default_auth_state_store.pending_state = "callback-state" From 1ff426d2e99ee90cf209a6c62d874f07f14e3752 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 17:38:32 +0200 Subject: [PATCH 15/17] Add pytest workflow --- .github/workflows/pytest.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/pytest.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 0000000..8435b44 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,31 @@ +name: pytest + +on: + push: + branches: + - "**" + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: | + requirements.txt + dev-requirements.txt + + - name: Install dependencies + run: python -m pip install -r dev-requirements.txt + + - name: Run pytest + run: python -m pytest \ No newline at end of file From 795c84f177cedd562a090a37990afe66ceb4d6e7 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 17:43:24 +0200 Subject: [PATCH 16/17] Stabilize auth tests in CI --- tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/conftest.py b/tests/conftest.py index 7edaab6..948661f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,6 +45,7 @@ def test_database_urls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("POO_DATABASE_URL", poo_database_url) monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") + monkeypatch.setenv("AUTH_COOKIE_SECURE_OVERRIDE", "false") get_settings.cache_clear() reset_auth_db_caches() From 1805d5d8ea95b6f7eb6163c654f8865f74d57385 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Mon, 20 Apr 2026 20:40:04 +0200 Subject: [PATCH 17/17] Finalize first Python release --- .env.example | 36 +- Dockerfile | 3 +- README.md | 86 +-- app/__init__.py | 2 +- app/main.py | 7 +- app/services/config_page.py | 12 + app/templates/base.html | 3 +- docker-compose.yml | 14 +- docker/entrypoint.sh | 9 + docs/current-system-inventory.md | 557 ------------------ docs/migration-notes.md | 134 ----- docs/migration-risks.md | 238 -------- docs/python-rewrite-plan.md | 314 ---------- legacy/README.md | 18 - .../go-backend/.github/workflows/nightly.yml | 22 - .../.github/workflows/short-tests.yml | 21 - .../home_automation_backend_template.conf | 15 - legacy/go-backend/helper/install.sh | 100 ---- legacy/go-backend/src/LICENSE | 0 legacy/go-backend/src/cmd/root.go | 41 -- legacy/go-backend/src/cmd/serve.go | 161 ----- .../components/homeassistant/homeassistant.go | 152 ----- .../homeassistant/homeassistant_test.go | 280 --------- .../locationRecorder/locationRecorder.go | 194 ------ .../src/components/pooRecorder/pooRecorder.go | 366 ------------ legacy/go-backend/src/go.mod | 54 -- legacy/go-backend/src/go.sum | 140 ----- .../src/helper/location_recorder/LICENSE | 0 .../helper/location_recorder/cmd/addgpx.go | 40 -- .../src/helper/location_recorder/cmd/root.go | 51 -- .../src/helper/location_recorder/main.go | 11 - .../src/helper/poo_recorder_helper/LICENSE | 0 .../helper/poo_recorder_helper/cmd/reverse.go | 127 ---- .../helper/poo_recorder_helper/cmd/root.go | 39 -- .../src/helper/poo_recorder_helper/main.go | 11 - legacy/go-backend/src/main.go | 11 - .../homeassistantutil/homeassistantutil.go | 96 --- legacy/go-backend/src/util/notion/notion.go | 129 ---- .../src/util/ticktickutil/ticktickutil.go | 297 ---------- openapi/openapi.json | 40 +- openapi/openapi.yaml | 28 +- pyproject.toml | 2 +- tests/test_app.py | 42 ++ 43 files changed, 215 insertions(+), 3688 deletions(-) create mode 100755 docker/entrypoint.sh delete mode 100644 docs/current-system-inventory.md delete mode 100644 docs/migration-notes.md delete mode 100644 docs/migration-risks.md delete mode 100644 docs/python-rewrite-plan.md delete mode 100644 legacy/README.md delete mode 100644 legacy/go-backend/.github/workflows/nightly.yml delete mode 100644 legacy/go-backend/.github/workflows/short-tests.yml delete mode 100644 legacy/go-backend/helper/home_automation_backend_template.conf delete mode 100755 legacy/go-backend/helper/install.sh delete mode 100644 legacy/go-backend/src/LICENSE delete mode 100644 legacy/go-backend/src/cmd/root.go delete mode 100644 legacy/go-backend/src/cmd/serve.go delete mode 100644 legacy/go-backend/src/components/homeassistant/homeassistant.go delete mode 100644 legacy/go-backend/src/components/homeassistant/homeassistant_test.go delete mode 100644 legacy/go-backend/src/components/locationRecorder/locationRecorder.go delete mode 100644 legacy/go-backend/src/components/pooRecorder/pooRecorder.go delete mode 100644 legacy/go-backend/src/go.mod delete mode 100644 legacy/go-backend/src/go.sum delete mode 100644 legacy/go-backend/src/helper/location_recorder/LICENSE delete mode 100644 legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go delete mode 100644 legacy/go-backend/src/helper/location_recorder/cmd/root.go delete mode 100644 legacy/go-backend/src/helper/location_recorder/main.go delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/LICENSE delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go delete mode 100644 legacy/go-backend/src/helper/poo_recorder_helper/main.go delete mode 100644 legacy/go-backend/src/main.go delete mode 100644 legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go delete mode 100644 legacy/go-backend/src/util/notion/notion.go delete mode 100644 legacy/go-backend/src/util/ticktickutil/ticktickutil.go diff --git a/.env.example b/.env.example index 17eb090..4156f17 100644 --- a/.env.example +++ b/.env.example @@ -1,22 +1,32 @@ +# Required: bootstrap and core app settings. +# These values should be set before the container starts. APP_NAME=Home Automation Backend (Python) APP_ENV=production -APP_DEBUG=false APP_HOSTNAME=home-automation.example.com -APP_DATABASE_URL=sqlite:///./data/app.db +APP_DATABASE_URL=sqlite:////app/data/app.db +LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db +POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db AUTH_BOOTSTRAP_USERNAME=admin -AUTH_BOOTSTRAP_PASSWORD=admin -AUTH_SESSION_COOKIE_NAME=home_automation_session -AUTH_SESSION_TTL_HOURS=12 -AUTH_COOKIE_SECURE_OVERRIDE=true -LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db -POO_DATABASE_URL=sqlite:///./data/pooRecorder.db +AUTH_BOOTSTRAP_PASSWORD=change-me + +# Optional: runtime overrides. +# Leave these commented out to use the application's built-in defaults. +# APP_DEBUG= +# AUTH_SESSION_COOKIE_NAME= +# AUTH_SESSION_TTL_HOURS= +# AUTH_COOKIE_SECURE_OVERRIDE= + +# Optional: Home Assistant integration. +# Leave these empty when Home Assistant integration is not needed. +HOME_ASSISTANT_BASE_URL= +HOME_ASSISTANT_AUTH_TOKEN= POO_WEBHOOK_ID= -POO_SENSOR_ENTITY_NAME=sensor.test_poo_status -POO_SENSOR_FRIENDLY_NAME=Poo Status +POO_SENSOR_ENTITY_NAME= +POO_SENSOR_FRIENDLY_NAME= + +# Optional: TickTick integration. +# APP_HOSTNAME is used to derive the OAuth callback URI automatically. TICKTICK_CLIENT_ID= TICKTICK_CLIENT_SECRET= TICKTICK_TOKEN= -HOME_ASSISTANT_BASE_URL=http://localhost:8123 -HOME_ASSISTANT_AUTH_TOKEN= -HOME_ASSISTANT_TIMEOUT_SECONDS=1.0 HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= diff --git a/Dockerfile b/Dockerfile index bc82a1f..d760173 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,9 +16,10 @@ COPY alembic_location.ini ./ COPY alembic_poo ./alembic_poo COPY alembic_poo.ini ./ COPY scripts ./scripts +COPY docker ./docker COPY README.md ./ RUN mkdir -p /app/data EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/README.md b/README.md index 44742d4..6631f21 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,24 @@ # Home Automation Backend -这是当前 `home-automation` 项目的 Python 重构基础骨架。当前仓库仍保留 Go 版本作为事实基线,而这个 Python 部分的目标是为后续逐模块迁移提供稳定工程基础。 +这是当前 `home-automation` 项目的首个 Python 版本。 -为便于清理仓库,重构开始前就存在的 Go 实现和相关资产已经统一移动到 `legacy/go-backend/`。这样在 Python 重构完成后,可以按目录整体删除旧实现。 +当前系统已经包含: -当前阶段只包含: +- FastAPI Web 应用与服务端模板页面 +- SQLite + SQLAlchemy + Alembic 的三库结构 +- username/password + server-side session 鉴权 +- runtime config 页面与 app DB 持久化 +- location recorder +- poo recorder +- Home Assistant inbound / outbound integration +- TickTick OAuth 与 action task 集成 +- pytest 测试与 OpenAPI 导出脚本 +- Docker / Compose 部署入口 -- FastAPI 基础应用骨架 -- 环境变量配置体系 -- SQLite + SQLAlchemy + Alembic 基础设施 -- username/password + server-side session 基础鉴权 -- 极简 server-side templates -- location recorder 第一版迁移 -- poo recorder 第一版迁移 -- Home Assistant outbound integration layer -- Home Assistant inbound gateway 第一版 -- pytest 测试基础 -- OpenAPI 导出脚本 -- Docker / Compose 基础骨架 +当前明确不包含: -当前阶段明确不包含: - -- TickTick 业务逻辑迁移 - Notion 模块 -当前 Home Assistant inbound gateway 仅接回第一版: - -- 已支持 `location_recorder / record` -- 尚未接回 TickTick 路径 -- 尚未接回 poo recorder 路径 - -Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 - -旧 Go 代码位置: - -- `legacy/go-backend/src/` -- `legacy/go-backend/helper/` -- `legacy/go-backend/.github/workflows/` - ## 当前配置现实 当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库: @@ -68,14 +49,14 @@ Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed sco ## 当前目录 -Python 骨架的主要目录如下: +主要目录如下: - `app/`: FastAPI 应用代码 - `alembic_app/`: App DB 的 Alembic migration 环境 - `alembic_location/`: Location DB 的 Alembic migration 环境 - `alembic_poo/`: Poo DB 的 Alembic migration 环境 - `tests/`: pytest 测试 -- `docs/`: 架构说明与迁移文档 +- `docs/`: 当前系统说明文档 - `scripts/`: 辅助脚本,例如 OpenAPI 导出 ## 依赖管理 @@ -146,7 +127,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 数据库与 Alembic -当前默认仍使用 SQLite,但要明确区分三个数据库文件: +当前默认使用 SQLite,并区分三个数据库文件: - App DB:`sqlite:///./data/app.db` - Location DB:`sqlite:///./data/locationRecorder.db` @@ -166,7 +147,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 基础鉴权 -当前项目已经有一层小范围的基础鉴权,目标是先保护后续配置页面,而不是现在就做完整 admin system。 +当前项目提供一个单用户 admin 鉴权层,用于保护配置页面与管理能力。 - 认证模型:`username/password` - 会话模型:server-side session + cookie @@ -194,7 +175,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。 -当前前端已经收敛为两条主路径: +当前前端主要有两条页面路径: - `/login` - `/config` @@ -203,7 +184,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## Config 持久化 -当前 config 页面已经不再把修改写回 `.env`。 +当前 config 页面不会把修改写回 `.env`。 当前原则是: @@ -219,6 +200,35 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中 - 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储 +## OpenAPI + +可使用下面的脚本重新导出当前 API 定义: + +```bash +python scripts/export_openapi.py +``` + +导出结果会写入: + +- `openapi/openapi.json` +- `openapi/openapi.yaml` + +## Docker Compose + +当前默认 Compose 服务名为 `app`,容器名固定为 `home-automation-app`。 + +启动方式: + +```bash +docker compose up -d --build +``` + +持续查看日志: + +```bash +docker compose logs -f app +``` + ## 运行测试 ```bash diff --git a/app/__init__.py b/app/__init__.py index 09cd07d..1780289 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,2 +1,2 @@ -"""Application package for the Python rewrite skeleton.""" +"""Application package for the home automation backend.""" diff --git a/app/main.py b/app/main.py index ea4e01d..7dddb19 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from app.api.routes.poo import router as poo_router from app.api.routes.ticktick import router as ticktick_router from app.config import get_settings from app.services.auth import AuthBootstrapError, initialize_auth_schema -from app.services.config_page import seed_missing_config_from_bootstrap +from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db @@ -28,6 +28,7 @@ def ensure_auth_db_ready() -> None: validate_app_runtime_db(get_settings().app_database_url) initialize_auth_schema(session, get_settings()) seed_missing_config_from_bootstrap(session, get_settings()) + sync_app_hostname_from_bootstrap(session, get_settings()) except AppDatabaseAdoptionError as exc: raise RuntimeError(str(exc)) from exc except AuthBootstrapError as exc: @@ -82,8 +83,8 @@ def create_app() -> FastAPI: version="0.1.0", lifespan=lifespan, description=( - "Python rewrite skeleton for the home automation backend. " - "This stage provides only the foundation for future module migration." + "Home automation backend with auth, runtime config, Home Assistant " + "integrations, TickTick integration, and SQLite-backed recorders." ), ) diff --git a/app/services/config_page.py b/app/services/config_page.py index 38ff16e..5a68621 100644 --- a/app/services/config_page.py +++ b/app/services/config_page.py @@ -109,6 +109,18 @@ def seed_missing_config_from_bootstrap(session: Session, bootstrap_settings: Set _persist_config_values(session, {**current_values, **missing_values}) +def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Settings) -> None: + current_values = _read_config_values(session) + bootstrap_hostname = _stringify(bootstrap_settings.app_hostname) + if current_values.get("APP_HOSTNAME") == bootstrap_hostname: + return + + current_values["APP_HOSTNAME"] = bootstrap_hostname + _persist_config_values(session, current_values) + get_settings.cache_clear() + reset_auth_db_caches() + + def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings: overrides = _read_config_values(session) if not overrides: diff --git a/app/templates/base.html b/app/templates/base.html index 5c55712..e5c583f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -4,7 +4,8 @@ {% block title %}{{ app_name }}{% endblock %} - + +
diff --git a/docker-compose.yml b/docker-compose.yml index 2cab369..87db840 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,12 @@ -version: "3.9" - services: app: + container_name: home-automation-app build: . + user: "1000:1000" + restart: unless-stopped + init: true ports: - - "${APP_PORT:-8000}:8000" - env_file: - - .env - environment: - LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db - POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db + - "127.0.0.1:8881:8000" volumes: - ./data:/app/data + - ./.env:/app/.env:ro diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..e69c8d1 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +set -eu + +python scripts/app_db_adopt.py +python scripts/location_db_adopt.py +python scripts/poo_db_adopt.py + +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/docs/current-system-inventory.md b/docs/current-system-inventory.md deleted file mode 100644 index c85cfb1..0000000 --- a/docs/current-system-inventory.md +++ /dev/null @@ -1,557 +0,0 @@ -# 当前系统盘点 - -本文档用于盘点当前 branch 上的 Go 实现,并将其作为后续 Python 重构的唯一事实基线。 - -## 范围与基线 - -- 当前事实基线:`legacy/go-backend/src/` 下的 Go 代码 -- 不纳入当前基线:更早的 Python 版本 -- 主入口:[`legacy/go-backend/src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62) - -## 系统概览 - -当前应用是一个单进程 Go HTTP 服务,具备以下特征: - -- 暴露少量 REST API -- 使用本地 SQLite 做持久化 -- 调用 Home Assistant API 和 webhook -- 通过 OAuth 和 REST API 集成 TickTick -- 当前仍依赖 Notion 做 poo 记录同步 -- 内置一个每日执行的定时同步任务 - -进程启动后会先读取 YAML 配置文件,再初始化 Notion 与 TickTick 工具层、初始化各业务组件自己的 SQLite 数据库、注册路由、启动调度器,最后在配置的端口上提供 HTTP 服务。可参考 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) 和 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:104)。 - -## API 盘点 - -### `GET /status` - -- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:106) -- 用途:基础存活检查 -- 请求参数:无 -- 请求体:无 -- 响应:纯文本 `OK` -- 鉴权:当前代码中无鉴权 -- 调用方类型:通用健康检查,可能用于本地监控或 supervisor 级别的探活 - -### `GET /poo/latest` - -- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:110) -- 处理函数:[`pooRecorder.HandleNotifyLatestPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:87) -- 用途:将最新一条 poo 状态重新发布到 Home Assistant 的 sensor state -- 请求参数:无 -- 请求体:无 -- 响应: - - 成功:空响应体,默认 HTTP 200 - - 失败:通过 `http.Error(...)` 返回文本错误信息 -- 鉴权:当前代码中无鉴权 -- 外部调用方: - - 会被 `POST /homeassistant/publish` 间接触发,当 `target=poo_recorder` 且 `action=get_latest` 时,代码会通过内部 HTTP 请求访问 `http://localhost:{port}/poo/latest`,见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:110) -- 副作用: - - 从 `poo_records` 读取最新一条记录 - - 调用 Home Assistant `/api/states/{entity_id}` 更新 sensor 状态,见 [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:65) - -### `POST /poo/record` - -- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:111) -- 处理函数:[`pooRecorder.HandleRecordPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57) -- 用途:记录一条 poo 事件,同时镜像到 Notion、刷新 Home Assistant sensor,并可选触发一个 Home Assistant webhook -- 请求体 JSON: - - `status: string` - - `latitude: string` - - `longitude: string` -- 请求校验: - - JSON decoder 开启了 `DisallowUnknownFields` - - 如果配置里缺少 `pooRecorder.tableId`,请求会直接失败,虽然从纯本地 DB 角度看本来仍有可能写入成功 -- 响应: - - 成功:空响应体,默认 HTTP 200 - - 请求错误:返回 decoder 错误文本,HTTP 400 - - 服务端错误:返回错误文本,HTTP 500 -- 鉴权:当前代码中无鉴权 -- 外部调用方:大概率是 Home Assistant、移动端 shortcut、或手工调用;代码中没有明确写死调用方 -- 副作用: - - 向 SQLite `poo_records` 插入一条记录 - - 异步向 Notion 追加一行 - - 同步发布最新 Home Assistant sensor 状态 - - 如果存在 `pooRecorder.webhookId`,异步触发一个 Home Assistant webhook - -### `POST /homeassistant/publish` - -- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:112) -- 处理函数:[`HomeAssistant.HandleHaMessage`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36) -- 用途:接收自动化消息,根据 `target` 和 `action` 做分发 -- 请求体 JSON: - - `target: string` - - `action: string` - - `content: string` -- 请求校验: - - JSON decoder 开启了 `DisallowUnknownFields` -- 响应: - - 成功路径:空响应体,默认 HTTP 200 - - 失败路径:通常为空响应体并返回 HTTP 500;TickTick auth 回调是单独的接口,不在这里 -- 鉴权:当前代码中无鉴权 -- 外部调用方:设计意图上是给 Home Assistant automation 消息调用 - -当前代码支持的消息契约如下: - -- `target=poo_recorder`, `action=get_latest` - - 转发到本地 `GET /poo/latest` - - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:72) - -- `target=location_recorder`, `action=record` - - `content` 预期是一个 JSON 风格字符串,实际很可能使用单引号 - - 当前代码会用 `strings.ReplaceAll(message.Content, "'", "\"")` 做归一化 - - 然后转发到本地 `POST /location/record` - - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82) - -- `target=ticktick`, `action=create_action_task` - - `content` 预期可解析为: - - `action: string` - - `due_hour: int` - - 当前代码会忽略调用方传来的 `title` 字段,而是把 `action` 映射为 TickTick task title - - 到期时间的计算方式是:取 `now + due_hour` 后所在日期的“次日零点”,再转成 TickTick 使用的时间格式 - - 最终在配置指定的 TickTick project 中创建任务 - - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:124) - -不支持的 `target` 或 `action` 会返回 HTTP 500,并打 warning 日志。相关测试在 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:68)。 - -### `POST /location/record` - -- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:114) -- 处理函数:[`locationRecorder.HandleRecordLocation`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:43) -- 用途:记录人的位置点,用于人生轨迹 / movement history -- 请求体 JSON: - - `person: string` - - `latitude: string` - - `longitude: string` - - `altitude: string`,从请求结构上看是可选,但代码里即使为空也会被解析成 `0` -- 请求校验: - - JSON decoder 开启了 `DisallowUnknownFields` - - 数值解析错误会被忽略;如果 `latitude` / `longitude` / `altitude` 不是合法数字,当前实现会静默落成 `0` -- 响应: - - 成功:空响应体,默认 HTTP 200 - - 请求错误:返回 decoder 错误文本,HTTP 400 -- 鉴权:当前代码中无鉴权 -- 外部调用方: - - 可被任意客户端直接调用 - - 也会被 `POST /homeassistant/publish` 间接触发 -- 副作用: - - 向 SQLite `location` 表插入一条记录 - -### `GET /ticktick/auth/code` - -- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:116) -- 处理函数:[`TicktickUtilImpl.HandleAuthCode`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103) -- 用途:TickTick OAuth redirect callback -- Query 参数: - - `state` - - `code` -- 响应: - - 成功:纯文本 `Authorization successful` - - 失败:纯文本错误信息,HTTP 400 或 500 -- 鉴权: - - 通过 OAuth `state` 与进程内保存的 `authState` 做校验 - - 没有额外的 session 或用户级鉴权 -- 外部调用方:TickTick OAuth redirect -- 副作用: - - 用 authorization code 换取 access token - - 通过 `viper.WriteConfig()` 把 `ticktick.token` 写回 YAML 配置文件 - -## 外部集成盘点 - -### TickTick - -- 在 Python 重构中的状态:应保留 -- 主要文件: - - [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:1) - - [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:100) -- 当前职责: - - 初始化 TickTick 鉴权状态 - - 当 token 缺失时启动 OAuth 授权 - - 接收 OAuth callback 并持久化 token - - 读取 project 下的 tasks - - 若不存在同名任务,则创建新任务 -- 连接方式: - - OAuth authorization code flow - - 调用 `https://ticktick.com/oauth/token` - - 调用 `https://api.ticktick.com/open/v1/...` -- 依赖的配置项: - - `ticktick.clientId` - - `ticktick.clientSecret` - - `ticktick.redirectUri` - - `ticktick.token` -- 关键实现依赖: - - 原生 `net/http` - - `viper`,同时承担配置读取和配置回写 -- 迁移高风险点: - - OAuth callback 的 `state` 只保存在进程内;如果服务在授权开始和回调完成之间重启,流程会断 - - token 直接写回 YAML 配置文件,虽然简单,但运维上比较脆弱 - - 去重逻辑只按 task title 精确匹配 - - due date 的计算语义是隐含在代码里的,重构前应先冻结 - - `Init()` 会在启动时积极检查配置,且在 token 缺失时打印手动授权 URL;Python 版需要明确是否仍要在启动阶段卡住这一流程 - -### Home Assistant - -- 在 Python 重构中的状态:应保留 -- 主要文件: - - [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:1) - - [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:1) -- 当前职责: - - 接收来自 Home Assistant automations 的命令 envelope - - 将命令转发给本地模块 - - 把 sensor state 发布回 Home Assistant - - 在 poo 记录后触发 Home Assistant webhook -- 连接方式: - - 入站 webhook 风格 JSON 接口:`POST /homeassistant/publish` - - 出站 REST 调用:Home Assistant `/api/states/{entity_id}` - - 出站 webhook 调用:`/api/webhook/{webhook_id}` - - 出站调用使用 bearer token -- 依赖的配置项: - - `homeassistant.ip` - - `homeassistant.port` - - `homeassistant.authToken` - - `homeassistant.actionTaskProjectId` - - `pooRecorder.webhookId` - - `pooRecorder.sensorEntityName` - - `pooRecorder.sensorFriendlyName` -- 关键实现依赖: - - 原生 `net/http` - - 通过 `localhost:{port}` 发起自调用,而不是直接走函数调用 -- 迁移高风险点: - - 入站 `/homeassistant/publish` 当前没有鉴权 - - 当前命令 envelope 里的 `content` 是字符串,且常带单引号,现有客户端可能依赖这种非标准格式 - - 模块间当前是通过自调用 HTTP 和 1 秒 timeout 编排的 - - sensor 发布和 webhook 触发都属于强副作用行为,需要在兼容性测试里单独覆盖 - -### Notion - -- 在 Python 重构中的状态:当前存在,但按已知目标不计划默认保留 -- 主要文件: - - [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:1) - - [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191) - - helper CLI:[`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) -- 当前职责: - - 使用 config token 初始化 Notion client - - 读取 / 写入 poo 记录对应的表格行 - - 每日做 SQLite 和 Notion 的双向同步 - - 提供一个反转 Notion 表顺序的辅助 CLI -- 连接方式: - - 通过 `github.com/jomei/notionapi` 调用 Notion API - - token 鉴权 -- 依赖的配置项: - - `notion.token` - - `pooRecorder.tableId` -- 关键实现依赖: - - `github.com/jomei/notionapi` -- 迁移高风险点: - - 当前服务启动时如果缺少 `notion.token` 会直接退出,即便 Notion 并不是系统所有功能都需要的基础能力 - - `POST /poo/record` 当前要求 `pooRecorder.tableId` 存在,并会异步镜像到 Notion - - 每日定时同步会同时改写 Notion 和 SQLite,若 Python 版移除这一行为,数据一致性预期会发生变化 - -## 数据库与 Schema 盘点 - -### 数据库类型 - -- 当前使用 SQLite 做组件级持久化 -- poo recorder 中显式导入了 SQLite driver,见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:20) -- location recorder 也通过 driver 名 `sqlite` 打开 SQLite,见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:80) - -### Poo recorder 数据库 - -- 配置项:`pooRecorder.dbPath` -- 默认路径:`pooRecorder.db` -- migration 机制: - - 手写 `PRAGMA user_version` - - 当前有效版本可以认为是 `1` - - 目前只实现了 `0 -> 1` -- 表: - - `poo_records` - - schema 定义见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:162) -- 字段: - - `timestamp TEXT PRIMARY KEY` - - `status TEXT NOT NULL` - - `latitude REAL NOT NULL` - - `longitude REAL NOT NULL` -- 核心用途: - - 用于查询最新 poo 状态并发布到 Home Assistant sensor - - 作为本地 poo 历史的持久化来源 - - 作为 Notion 双向同步的本地数据源和数据汇 -- 明显核心字段: - - `timestamp` - - `status` - - `latitude` - - `longitude` -- 可能属于历史包袱 / 后续需要再判断的点: - - 当前实现与 Notion 表行结构高度耦合 - - 时间戳是字符串,格式为 `2006-01-02T15:04Z07:00`,不是带秒的完整 RFC3339 - - API 请求模型接受的经纬度是字符串,因此 Python 版的类型规范化要小心兼容 - -### Location recorder 数据库 - -- 配置项:`locationRecorder.dbPath` -- 默认路径:`location_recorder.db` -- migration 机制: - - 手写 `PRAGMA user_version` - - 当前版本 `2` - - 已实现 migration: - - `0 -> 1`:建表 - - `1 -> 2`:把旧 datetime 字符串改写成 RFC3339 UTC -- 表: - - `location` - - schema 定义见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:115) -- 字段: - - `person TEXT NOT NULL` - - `datetime TEXT NOT NULL` - - `latitude REAL NOT NULL` - - `longitude REAL NOT NULL` - - `altitude REAL` - - 主键 `(person, datetime)` -- 核心用途: - - 持久化人生轨迹 / 位置点记录 -- 明显核心字段: - - `person` - - `datetime` - - `latitude` - - `longitude` -- 可能属于历史包袱 / 后续需要再判断的点: - - `altitude` 在语义上是可选,但当前入站解析会把缺失和非法值一起压成 `0` - - 当前没有查询 API,这张表目前主要承担“只写不读”的存储角色 - -### 跨模块数据库观察 - -- 当前没有统一的共享 schema;每个组件各自打开自己的 SQLite 文件 -- 没有使用 ORM -- 没有统一 migration 框架 -- 除主键外,没有看到额外索引 -- `poo` 的常规写入和异步 Notion 镜像之间没有事务保证,一致性更接近 best-effort - -## 业务模块拆分 - -### 1. HTTP 外壳 / 应用启动层 - -- 职责: - - 读取配置 - - 设置日志级别 - - 管理 scheduler 生命周期 - - 注册路由 - - 处理优雅退出 -- 主要文件:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62) -- 依赖: - - 所有业务模块 -- 迁移判断: - - 这部分后续应成为 FastAPI 的 app 装配层 - - 可以在早期先迁为“薄壳” - -### 2. Poo recorder - -- 职责: - - 接收 poo 记录 - - 持久化本地 poo 历史 - - 向 Home Assistant 发布最新状态 sensor - - 触发可选 Home Assistant webhook - - 与 Notion 做同步 -- 主要文件:[`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:50) -- 依赖: - - SQLite - - Home Assistant util - - Notion util - - scheduler -- 迁移判断: - - 这是功能上重要、但耦合也最重的模块 - - 适合在设计上先拆成: - - poo API / service - - Home Assistant 发布适配层 - - legacy Notion sync adapter - -### 3. Location recorder - -- 职责: - - 接收位置更新 - - 持久化人生轨迹点 -- 主要文件:[`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:39) -- 依赖: - - SQLite -- 迁移判断: - - 相对独立 - - 很适合作为优先迁移对象 - - 但需要先明确数值校验规则,因为当前实现会把非法数字静默压成 `0` - -### 4. Home Assistant 命令路由层 - -- 职责: - - 接收命令 envelope - - 根据 target/action 调度 poo、location、ticktick 行为 -- 主要文件:[`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36) -- 依赖: - - 本地服务端口 - - TickTick util -- 迁移判断: - - 它是外部 automations 的关键契约 - - 应在迁移早中期就被冻结和复刻 - -### 5. TickTick adapter - -- 职责: - - OAuth callback - - project / task REST 操作 - - 任务去重 -- 主要文件:[`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:81) -- 依赖: - - TickTick API - - 可写配置文件 -- 迁移判断: - - 作为内部 adapter 相对独立 - - 复杂度中等,主要难点是 OAuth 和 token 持久化 - -### 6. Home Assistant 出站 client - -- 职责: - - 发布 sensor state - - 触发 webhook -- 主要文件:[`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:30) -- 依赖: - - Home Assistant API/token -- 迁移判断: - - 小而独立 - - 很适合作为较早迁移的适配层 - -### 7. Notion adapter 与辅助 CLI - -- 职责: - - 读写 Notion table rows - - 维护 poo 相关表格的辅助操作 -- 主要文件: - - [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:14) - - [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) -- 迁移判断: - - 当前存在,但按已知方向应视为 planned non-migration - - 更适合被标记为 legacy 模块,而不是直接带进 Python 主体 - -### 8. 辅助 helper CLI - -- `src/helper/poo_recorder_helper` - - 用于 Notion 表反转的运维辅助工具 -- `src/helper/location_recorder` - - 当前基本还是脚手架,没有实质业务逻辑 -- 迁移判断: - - 二者都不是后端重构的核心目标 - - `poo_recorder_helper` 应随着 Notion 一并视作 legacy - -## 运行方式与部署形态 - -### 配置方式 - -- 配置文件名:`config.yaml` -- 搜索路径: - - 当前工作目录 - - `$HOME/.config/home-automation` -- 配置加载代码:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) -- 开启了 `viper.WatchConfig()` - -代码中实际出现的配置项包括: - -- `port` -- `logLevel` -- `notion.token` -- `ticktick.clientId` -- `ticktick.clientSecret` -- `ticktick.redirectUri` -- `ticktick.token` -- `homeassistant.ip` -- `homeassistant.port` -- `homeassistant.authToken` -- `homeassistant.actionTaskProjectId` -- `pooRecorder.tableId` -- `pooRecorder.webhookId` -- `pooRecorder.sensorEntityName` -- `pooRecorder.sensorFriendlyName` -- `pooRecorder.dbPath` -- `locationRecorder.dbPath` - -### 进程模型 - -- 单个二进制,通过 Cobra 子命令 `serve` 启动 -- 监听 `SIGINT` / `SIGTERM` 做优雅退出 -- scheduler 与 HTTP server 在同一进程内运行 -- 路由层使用 `gorilla/mux` - -### 定时任务 - -- 每天 `0 5 * * *` 执行一次 poo records 与 Notion 的同步 -- 定义在 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180) - -### 被动接收式接口 - -- 上面列出的入站 REST API -- TickTick OAuth callback endpoint - -### 运行时依赖的外部服务 - -- Home Assistant HTTP API 与 webhook -- TickTick OAuth 与 REST API -- 当前 poo 流程仍依赖 Notion API -- 本地可写文件系统,用于配置文件和 SQLite DB - -### 当前本地 / 服务部署形态 - -- 安装脚本会构建 Go 二进制,并安装到 `$HOME/.local/home-automation-backend` -- 使用 Supervisor 管理进程 -- 生成的 supervisor 配置最终执行 `{binary} serve` -- 参考: - - [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45) - - [`helper/home_automation_backend_template.conf`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/home_automation_backend_template.conf:1) - -### 容器化情况 - -- 这一轮代码扫描中没有发现 `Dockerfile` 或 compose 文件 -- 当前部署形态是 supervisor-based,而不是 container-based - -## 测试与文档现状 - -### 测试现状 - -- 只发现一个测试文件:[`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1) -- 当前测试覆盖: - - 入站命令 JSON 解码 - - target/action 路由分发 - - 转发到 poo/location handler 的行为 - - TickTick task 创建委托 - - 错误日志和失败路径 -- 当前没有覆盖: - - poo recorder 的 DB 行为 - - location recorder 的 DB 行为 - - TickTick OAuth 流程 - - Home Assistant 出站发布 - - Notion sync 逻辑 - - 启动与配置加载 - - scheduler 行为 - -### 本轮测试执行情况 - -- 我尝试在 `legacy/go-backend/src/` 下执行 `go test ./...` -- 但当前会话环境里没有安装 `go` 命令,因此无法实际运行测试 -- 所以这轮关于测试的判断,基于静态阅读,而不是实际执行结果 - -### 文档现状 - -- 仓库里的 `README.md` 基本只有标题和 badge,内容非常少 -- 没有用户可读的 API 文档 -- 没有 schema 文档 -- 没有 Home Assistant / TickTick 的契约说明文档 -- 没有关于配置项、OAuth 初始化、数据库文件位置的运维文档 - -### 后续最需要补齐的文档 - -- Home Assistant 命令 envelope 与支持的 action -- 各 API 的 request / response 契约 -- TickTick OAuth 初始化与 token 持久化方式 -- 数据库归属、用途与保留策略 -- Notion 下线 / 不迁移说明 - -## 对 Python 重构特别重要的事实 - -- 当前 API 行为整体比较“轻响应、重副作用”,很多成功请求返回的都是空响应体 -- 当前所有入站 API 都没有看到鉴权 -- 当前系统本地真相来源是 SQLite,但 poo 数据还同时与 Notion 同步 -- `notion.token` 现在不是可选项,缺失时服务会在 `initUtil()` 阶段直接退出 -- Home Assistant 命令路由当前是通过本地 HTTP 自调用实现的,而不是直接服务层调用 -- TickTick callback 会改写应用本身使用的 YAML 配置文件 diff --git a/docs/migration-notes.md b/docs/migration-notes.md deleted file mode 100644 index ef64a5c..0000000 --- a/docs/migration-notes.md +++ /dev/null @@ -1,134 +0,0 @@ -# Migration Notes - -本文档记录 Python skeleton 阶段的迁移说明,帮助后续继续推进时快速恢复上下文。 - -## 当前阶段完成内容 - -- 建立 FastAPI 应用骨架 -- 建立环境变量配置体系 -- 接入 SQLAlchemy 与 Alembic -- 建立 Jinja2 模板基础 -- 建立 pytest 基础设施 -- 建立 Docker / Compose 基础骨架 -- 建立 OpenAPI 导出脚本 -- 迁入 `location recorder` 第一版 -- 迁入 `poo recorder` 第一版 - -## 数据库配置现状 - -当前系统在配置层上已明确保留两个独立 SQLite DB 文件: - -- `LOCATION_DATABASE_URL` -- `POO_DATABASE_URL` - -当前阶段不打算把这两个数据库合并。 - -其中: - -- `location` 模块已经实际接到 `LOCATION_DATABASE_URL` -- `poo` 模块已经实际接到 `POO_DATABASE_URL` - -## 当前阶段未做内容 - -- 未迁移 TickTick 业务逻辑 -- 未迁移 Home Assistant inbound / outbound 之外的其他业务逻辑 -- 未实现真实 OAuth 流程 -- 未做数据迁移 - -## Location recorder 说明 - -当前 Python 项目已经接入 `POST /location/record`,并对齐 legacy SQLite schema: - -```sql -CREATE TABLE location ( - person TEXT NOT NULL, - datetime TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - altitude REAL, - PRIMARY KEY (person, datetime) -); -``` - -当前已经补上最小 Alembic baseline / 接管策略: - -- `location` 当前 schema 被视为 Alembic baseline -- 新数据库通过 `alembic upgrade head` 初始化 -- 已有 legacy SQLite 数据库通过 `alembic stamp` 接管 -- `PRAGMA user_version = 2` 仅保留为历史事实,不再作为新的主 migration 机制 - -详见: - -- [location-recorder.md](location-recorder.md) - -当前还额外提供了一个最小 runbook / script 组合,用于保守接管 legacy location DB: - -- 先严格校验 schema -- 再严格校验 `PRAGMA user_version = 2` -- 只有全部匹配才执行 Alembic `stamp` -- 不匹配则直接失败,不自动修复 - -同时,应用启动阶段现在也会对 location DB 做保守的只读校验: - -- DB 文件不存在时拒绝启动 -- DB 尚未被 Alembic 接管时拒绝启动 -- DB revision 与当前应用预期不一致时拒绝启动 - -## Poo recorder 说明 - -当前 Python 项目已经接入: - -- `POST /poo/record` -- `GET /poo/latest` - -并对齐当前真实 baseline schema: - -```sql -CREATE TABLE poo_records ( - timestamp TEXT NOT NULL, - status TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - PRIMARY KEY (timestamp) -); -``` - -历史上 legacy Go 实现使用: - -```sql -PRAGMA user_version = 1; -``` - -当前已经补上与 location 一致风格的 Alembic baseline / 接管策略: - -- `poo_records` 当前 schema 被视为 Alembic baseline -- 新数据库通过 `alembic_poo upgrade head` 初始化 -- 已有 legacy SQLite 数据库通过 `alembic stamp` 接管 -- `PRAGMA user_version = 1` 仅保留为历史事实,不再作为新的主 migration 机制 - -同时这一轮明确移除了 Notion: - -- 不迁 Notion sync -- 不迁 Notion adapter -- `POST /poo/record` 不再依赖 `tableId` 才能写入 - -详见: - -- [poo-recorder.md](poo-recorder.md) - -## 后续建议顺序 - -建议继续沿用既有迁移文档中的顺序: - -1. 先迁 `location recorder` -2. 再迁 Home Assistant 出站适配层 -3. 再迁 Home Assistant 命令网关 -4. 再迁 `poo recorder` -5. 最后迁 TickTick adapter - -## 开发约束提醒 - -- 保持对当前 Go 外部行为的兼容意识 -- 不要把旧 Python 版本当作设计基线 -- 不要重新引入 Notion 作为 Python 主系统能力 -- 在迁业务模块时,优先补 contract tests diff --git a/docs/migration-risks.md b/docs/migration-risks.md deleted file mode 100644 index 209cde0..0000000 --- a/docs/migration-risks.md +++ /dev/null @@ -1,238 +0,0 @@ -# 迁移风险清单 - -本文档列出将当前 Go 后端重构为 Python 时的主要风险点。这里的风险判断,默认都是以“当前 Go 行为需要尽量兼容”为前提。 - -## 最高风险区域 - -### 1. `POST /homeassistant/publish` 存在隐式行为契约 - -风险: - -- 当前 Home Assistant 网关使用 `target`、`action`、`content` 这种 envelope -- `content` 不是标准嵌套 JSON,而是字符串化 payload -- 有些 payload 明显依赖单引号 pseudo-JSON,再在代码中做归一化 - -为什么重要: - -- 如果 Python 版过早改成“严格、干净、标准化的嵌套 JSON”,现有 Home Assistant automations 可能直接失效 - -当前证据: - -- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82) -- 测试见 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:129) - -缓解建议: - -- 在重构前先收集真实线上 payload 样例 -- 第一阶段保留兼容性解析 -- 为所有当前支持的 `target/action` 增加回归测试 - -### 2. TickTick OAuth 与 token 持久化流程 - -风险: - -- OAuth `state` 当前只保存在进程内 -- token 获取后会直接写回 YAML 配置文件 - -为什么重要: - -- Python 重构很容易在不自觉的情况下改变操作流程 -- token 持久化语义一变,可能会带来难排查的鉴权失败 - -当前证据: - -- callback 与配置回写逻辑见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103) -- 授权 URL 初始化见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:275) - -缓解建议: - -- 在编码前先确定 token storage 方案 -- 保持 callback 契约稳定 -- 在 staging 环境用真实 TickTick app 做端到端验证 - -### 3. Poo recorder 的副作用比 API 表面看起来更复杂 - -风险: - -- `POST /poo/record` 不只是写一条 DB -- 它还会镜像到 Notion、发布 Home Assistant sensor、并且可能触发 Home Assistant webhook - -为什么重要: - -- 即使 Python 版 API 看起来兼容,如果漏掉这些副作用,也会导致真实自动化行为偏差 - -当前证据: - -- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57) -- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:97) -- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:339) - -缓解建议: - -- 把 endpoint 契约和 side-effect 契约分开写清楚 -- 在 Python 中通过显式 service / adapter 接口承接这些行为 -- 用 mock Home Assistant / TickTick / Notion 的方式做测试 - -### 4. 移除 Notion 会改变当前运行预期 - -风险: - -- Notion 虽然已经被识别为“不计划继续保留”,但它现在并不是边缘代码 -- 它当前参与启动、请求处理以及每日同步 - -为什么重要: - -- 去掉 Notion 会实质改变数据流 -- 也可能影响历史镜像、人工运维方式以及启动要求 - -当前证据: - -- 启动时强依赖 `notion.token`,见 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:41) -- 每日同步逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191) -- helper CLI 见 [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) - -缓解建议: - -- 在文档里显式说明 Notion 将被下线 / 不迁移 -- 先决定是否需要一次性历史导出或回填 -- 确保移除 Notion 后,`pooRecorder.tableId` 和 `notion.token` 不再阻塞服务启动 - -## 中等风险区域 - -### 5. SQLite 兼容性与时间戳格式 - -风险: - -- 当前代码把时间戳以文本形式存储 -- `location` 和 `poo` 两个模块使用的时间格式并不相同 - -为什么重要: - -- Python 重构若擅自统一时间格式,可能会破坏旧 DB 的可兼容读取 - -当前证据: - -- poo 时间戳写入逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:344) -- location 时间戳写入逻辑见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:61) - -缓解建议: - -- 先决定 Python 第一阶段是否直接复用现有 DB 文件 -- 如果要复用,就要保留当前时间戳序列化行为 -- 为数据层建立回归样例 - -### 6. 输入校验行为可能与 FastAPI 默认习惯冲突 - -风险: - -- FastAPI / Pydantic 通常更倾向严格校验 -- 当前 Go 代码的校验行为并不一致: - - 有些接口拒绝 unknown fields - - `location` 数值解析错误会被静默忽略 - - 很多成功响应是空响应体 - -为什么重要: - -- 更“正确”的校验,也可能是破坏兼容性的改动 - -当前证据: - -- 多个 handler 都开启了严格字段检查 -- `location` 的静默 float parsing 见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:54) - -缓解建议: - -- 先明确哪些怪异行为是必须兼容的,哪些可以修正 -- 第一阶段如果要保持兼容,可以在 Python 里用自定义校验逻辑模拟当前行为 - -### 7. 定时任务行为漂移 - -风险: - -- 当前应用内嵌了一个每天 `0 5 * * *` 执行的 Notion 同步任务 - -为什么重要: - -- 如果 Python 版仍保留类似行为,时区、执行时机、幂等性处理差异都可能导致重复或漏同步 - -当前证据: - -- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180) - -缓解建议: - -- 如果 Notion 被移除,就应有意识地同步移除 scheduler 相关逻辑,并写清楚原因 -- 如果在过渡期暂时保留,就要明确 timezone 与幂等语义 - -### 8. self-HTTP 编排改成 direct service calls 的差异 - -风险: - -- 当前 Home Assistant 网关通过调用 `localhost` 上的本地接口来驱动其它模块 - -为什么重要: - -- 改成直接函数 / service 调用本身是合理的,但可能改变 timeout、错误传播和日志行为 - -当前证据: - -- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:88) -- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:115) - -缓解建议: - -- 对外 HTTP 行为保持不变 -- 但在内部重写后,补足状态码和失败语义测试 - -## 风险较低但仍重要的区域 - -### 9. 部署模型变化 - -风险: - -- 当前部署方式是 supervisor-based -- 未来目标是容器化 - -为什么重要: - -- 启动文件路径、配置文件写入位置、token persistence 方式,在容器环境下都可能出问题 - -当前证据: - -- 安装脚本见 [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45) - -缓解建议: - -- 把运行时状态从镜像内容中解耦 -- 预先定义 DB/config 是否需要挂载 volume -- 在 cutover 前先写清楚 container env vars 与文件挂载约定 - -### 10. 现有测试过少 - -风险: - -- 当前大多数模块没有自动化测试 - -为什么重要: - -- 没有安全网时,重构很容易改坏行为而不自知 - -当前证据: - -- 当前只发现 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1) - -缓解建议: - -- 把 contract test 建设视作迁移工作的一部分,而不是迁移后的补票 -- 每迁一个模块,就同步补该模块的测试 - -## 总结 - -这次重构最大的风险,不是“Go 改 Python”本身,而是几个隐藏得很深的行为契约: - -- Home Assistant 命令 payload 的真实格式 -- TickTick 的 OAuth / token 生命周期 -- poo recorder 的一组副作用行为 -- 当前仍活跃、但计划下线的 Notion 耦合 - -只要先把这些契约写清楚、测清楚,再开始 Python 实现,整个重构路线就会可控很多。 diff --git a/docs/python-rewrite-plan.md b/docs/python-rewrite-plan.md deleted file mode 100644 index fcec9c2..0000000 --- a/docs/python-rewrite-plan.md +++ /dev/null @@ -1,314 +0,0 @@ -# Python 重构方案 - -本文档基于当前 Go 实现,给出迁移到 Python + FastAPI 的设计输入与建议顺序。本文档只讨论迁移方案,不代表已经开始实现。 - -## 重构原则 - -- 以当前 Go 实现为唯一事实来源 -- 先保持对外行为兼容,再考虑内部清理和优化 -- 明确区分“行为兼容”和“内部实现升级” -- 默认不把 Notion 纳入新的 Python 主版本目标,除非后续重新决策 -- 尽量按模块迁移,并在模块之间建立清晰契约 - -## 目标形态 - -建议的 Python 目标架构: - -- 用 FastAPI 提供 HTTP 路由,并自然生成 OpenAPI -- 业务逻辑按模块拆分到 service layer -- 外部系统对接放到 adapter layer -- SQLite 访问放到 repository layer -- 配置采用显式 settings model -- scheduler 是否内嵌在应用进程内,需要在启动语义明确后再决定 -- 仅保留极轻量的服务端页面,用于 OAuth 跳转或简单配置 - -## 建议的 Python 模块边界 - -### 应用外壳层 - -- 职责: - - 读取 settings - - 依赖注入与对象装配 - - 路由注册 - - lifespan hooks - - scheduler 启停 -- 在 FastAPI 中可对应: - - `main.py` 或 app factory - - settings 类 - -### Poo 领域模块 - -- 职责: - - 校验 poo record 输入 - - 持久化 poo record - - 查询 latest poo - - 通过接口触发外部副作用 -- 建议把副作用依赖抽象为端口: - - `PooRepository` - - `HomeAssistantPublisher` - - `HomeAssistantWebhookClient` - - 如有需要,可保留临时 `LegacyPooMirror` 作为 Notion 过渡适配器 - -### Location 领域模块 - -- 职责: - - 校验并持久化位置点 -- 可保持简洁: - - `LocationRepository` - - `LocationService` - -### Home Assistant 命令网关 - -- 职责: - - 暴露 `/homeassistant/publish` - - 解析 `target/action/content` - - 将命令分发到内部服务 -- 兼容性注意: - - 第一阶段应保留当前 `content` 的处理习惯,包括对字符串 payload 的兼容解析 - -### TickTick 集成模块 - -- 职责: - - OAuth start / callback - - token 存储 - - task 查询 - - 去重 - - task 创建 -- 建议: - - 把 token persistence 抽象成独立能力,而不是把“改写配置文件”直接塞进业务逻辑 - -### Home Assistant 出站适配层 - -- 职责: - - 发布 sensor state - - 触发 webhook -- 该层小而独立,适合较早迁移 - -### Legacy Notion 适配层 - -- 职责: - - 只在分析或过渡阶段表示当前行为 -- 默认建议: - - 不放入 Python 第一版正式目标 - - 如 cutover 期间确有需要,可以 feature flag 或独立迁移工具的方式暂存 - -## 实现前需要冻结的兼容契约 - -在正式编码前,建议先把以下当前行为写成明确契约: - -### API 契约 - -- `POST /poo/record` 的请求字段与当前“成功时空响应体”的行为 -- `POST /location/record` 的请求字段与数值解析行为 -- `POST /homeassistant/publish` 的 envelope 格式与支持的 `target/action` -- `GET /ticktick/auth/code` 的成功 / 失败语义 - -### 副作用契约 - -- `POST /poo/record` 在什么时机会发布 Home Assistant sensor -- `POST /poo/record` 在什么条件下会触发 Home Assistant webhook -- Home Assistant 消息如何映射为 TickTick task title 与 due date -- Home Assistant sensor payload 的结构 - -### 持久化契约 - -- 当前 SQLite 表名与主键 -- 当前磁盘上的时间戳格式 -- Python 第一阶段是否直接复用现有 DB 文件,还是做显式迁移 - -## 建议的迁移决策 - -### 决策 1:第一阶段保持对外 API 形状不变 - -原因: - -- 当前 API 面很小 -- 保持兼容能显著降低切换风险 -- 即使保持兼容,FastAPI 仍然可以生成 OpenAPI 文档 - -### 决策 2:把内部 self-HTTP 改成直接服务调用 - -原因: - -- 当前 Go 代码中的 `localhost` 自调用,本质上是内部编排手段 -- 这不是一个必须暴露给外部的契约 -- Python 版改为直接函数 / service 调用,可以提升清晰度和可测试性 - -### 决策 3:先继续使用 SQLite - -原因: - -- 当前系统已经使用 SQLite -- 数据模型规模很小 -- PostgreSQL 更适合作为 parity 之后的下一阶段演进 - -### 决策 4:默认不迁 Notion,但要明确记录影响 - -原因: - -- 你已经明确表示 Notion 很可能不继续保留 -- 当前 Notion 不是“代码里有但没在用”,而是真正参与运行逻辑 -- 所以不能静默删除,而要在方案中写清楚删掉后有什么影响 - -### 决策 5:把 token / auth persistence 做成显式设计 - -原因: - -- 当前 TickTick token 处理虽然可用,但运维上比较脆弱 -- Python 重构是一个把这件事规范化的机会 - -## 建议迁移顺序 - -### Phase 0:盘点与契约确认 - -- 完成当前系统 inventory -- 确认哪些当前行为是“必须兼容的契约”,哪些只是历史偶然实现 -- 明确把 Notion 标为 non-migration scope,除非后续重新决定 - -### Phase 1:Python 骨架与通用基础设施 - -- 建立 FastAPI app shell -- 定义 settings / config model -- 定义日志方案 -- 定义 SQLite 访问方式 -- 定义测试框架与 fixture 策略 -- 定义 OpenAPI 生成与导出方式 - -这一阶段不需要大量迁移业务逻辑,只要搭好后续模块可持续迁入的基础即可。 - -### Phase 2:先迁最独立、最稳定的业务模块 - -推荐优先迁移:`location recorder` - -原因: - -- 独立 SQLite 表 -- 没有复杂外部副作用 -- 没有 OAuth -- 没有 scheduler - -这一阶段的交付物可以包括: - -- `POST /location/record` -- 与现有 SQLite 兼容的写入逻辑 -- 校验与 repository 的单元测试 -- 基于临时 SQLite 的 integration test - -### Phase 3:迁移 Home Assistant 出站适配层 - -原因: - -- 功能面小 -- 能为后面的 poo 迁移做铺垫 - -这一阶段的交付物可以包括: - -- sensor publish client -- webhook trigger client -- 针对请求格式与错误处理的 mock tests - -### Phase 4:迁移 TickTick adapter - -原因: - -- 相对自洽 -- 在完成 Home Assistant 命令网关前就需要它 - -这一阶段的交付物可以包括: - -- OAuth callback endpoint -- token persistence abstraction -- task 创建与去重行为 -- 基于 mock HTTP 的集成式测试 - -### Phase 5:迁移 Home Assistant 命令网关 - -原因: - -- 这是外部 automations 的核心编排入口 -- 在 location 与 TickTick adapter 准备好后,网关迁移会顺很多 - -这一阶段的交付物可以包括: - -- `/homeassistant/publish` -- 兼容当前 `target/action` 的分发逻辑 -- 用进程内 service 调用替代 self-HTTP -- 把现有 Go 测试场景迁成 Python contract tests - -### Phase 6:迁移 poo recorder 核心,但默认不带 Notion - -原因: - -- 这是最复杂的模块 -- 它既有本地 DB,又有 Home Assistant 副作用,当前还耦合 Notion - -建议拆成两个子阶段: - -- phase 6a: - - 本地 poo DB - - latest poo 查询 - - sensor publish - - 可选 webhook trigger - - `/poo/record` - - `/poo/latest` - -- phase 6b: - - 如果 cutover 期间必须保留旧逻辑,再做一个临时 legacy Notion 兼容层 - -### Phase 7:运维加固与切换验证 - -- 做 Go / Python 路由级契约比对 -- 用现有 SQLite 文件或其副本做兼容验证 -- 在 staging 环境手动验证 TickTick OAuth -- 用真实 Home Assistant automation payload 做验证 -- 导出 OpenAPI YAML -- 再补容器化与部署方案 - -## 哪些模块适合先迁,哪些适合后迁 - -### 适合优先迁移 - -- location recorder -- Home Assistant 出站 client -- TickTick adapter - -### 更适合后迁 - -- Home Assistant 命令网关 -- poo recorder 核心 - -### 现状存在,但建议不迁 - -- Notion sync adapter -- `poo_recorder_helper` 的 Notion 表反转 CLI -- `location_recorder` helper CLI 脚手架 - -## 建议的验证策略 - -### Contract tests - -- 基于当前 Go 行为建立 request / response fixtures -- 先把现有 `homeassistant` 测试案例迁成 Python -- 补上 `poo` 与 `location` API 的契约测试 - -### Integration tests - -- 每个模块使用临时 SQLite DB -- Home Assistant 与 TickTick 出站流量通过 mock HTTP 替代 -- 若仍保留 scheduler,则为其补定时行为测试 - -### 手工 staging 验证 - -- TickTick OAuth callback -- Home Assistant sensor 更新 -- Home Assistant webhook 触发 -- 当前真实自动化 payload 样例 - -## 开始实现前仍需明确的问题 - -- Python 第一阶段是否还要保留“缺少 `notion.token` 就启动失败”的行为,还是直接把 Notion 变成可关闭能力? -- `POST /location/record` 是否要继续保留“非法数字静默变成 0”的兼容行为? -- TickTick token 在第一阶段是否继续写回 YAML,还是立即切到独立 token store? -- 当前 Home Assistant automations 是否真实依赖 `content` 中的单引号 pseudo-JSON? - -这些问题不影响当前 inventory,但会影响第一阶段“兼容到什么程度”的具体定义。 diff --git a/legacy/README.md b/legacy/README.md deleted file mode 100644 index d2a6e33..0000000 --- a/legacy/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Legacy Code - -这个目录用于收纳 Python 重构开始之前就已存在的旧实现与配套资产,方便在重构完成后整块删除。 - -当前已迁入: - -- `go-backend/src/` - - 旧 Go 后端实现 -- `go-backend/helper/` - - 旧 Go 部署与辅助脚本 -- `go-backend/.github/workflows/` - - 旧 Go 版本对应的 GitHub Actions workflows - -原则上: - -- 新的 Python 实现继续在仓库根目录的 `app/`、`tests/`、`alembic_location/`、`alembic_poo/` 等目录演进 -- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础 -- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/` diff --git a/legacy/go-backend/.github/workflows/nightly.yml b/legacy/go-backend/.github/workflows/nightly.yml deleted file mode 100644 index 592bf55..0000000 --- a/legacy/go-backend/.github/workflows/nightly.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Run nightly tests - -on: - schedule: - - cron: '0 20 * * *' # Every day at 20:00 UTC - push: - branches: - - main -jobs: - nightly-tests: - runs-on: [ubuntu-latest, cloud] - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.23' - - - name: Test - working-directory: ./src - run: go test -v --short ./... diff --git a/legacy/go-backend/.github/workflows/short-tests.yml b/legacy/go-backend/.github/workflows/short-tests.yml deleted file mode 100644 index 2f39588..0000000 --- a/legacy/go-backend/.github/workflows/short-tests.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Run short tests - -on: - push: - pull_request: - -jobs: - run-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: '1.24' - - - name: Run short tests with coverage - working-directory: ./src - run: | # TODO: at this moment only Home Assistant component is tested - go test -v --short ./components/homeassistant/... -cover -coverprofile=cover.out diff --git a/legacy/go-backend/helper/home_automation_backend_template.conf b/legacy/go-backend/helper/home_automation_backend_template.conf deleted file mode 100644 index 644c8a1..0000000 --- a/legacy/go-backend/helper/home_automation_backend_template.conf +++ /dev/null @@ -1,15 +0,0 @@ -[program:home_automation_backend] -command= -directory= -user= -group= -environment= -autostart=true -autorestart=true -startsecs=15 -startretries=100 -stopwaitsecs=30 -redirect_stderr=true -stdout_logfile=/var/log/supervisor/%(program_name)s.log -stdout_logfile_maxbytes=5MB -stdout_logfile_backups=5 \ No newline at end of file diff --git a/legacy/go-backend/helper/install.sh b/legacy/go-backend/helper/install.sh deleted file mode 100755 index df0c559..0000000 --- a/legacy/go-backend/helper/install.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/bash - -# Argument parsing -if [[ $# -ne 1 ]]; then - echo "Usage: $0 [--install|--uninstall|--help]" - echo " --install Install the automation backend" - echo " --uninstall Uninstall the automation backend" - echo " --update Update the installation" - echo " --help Show this help message" - exit 0 -fi - -key="$1" -case $key in - --install) - INSTALL=true - ;; - --uninstall) - UNINSTALL=true - ;; - --update) - UPDATE=true - ;; - --help) - echo "Usage: $0 [--install|--uninstall|--update|--help]" - echo " --install Install the automation backend" - echo " --uninstall Uninstall the automation backend" - echo " --update Update the installation" - echo " --help Show this help message" - exit 0 - ;; - *) - echo "Invalid argument: $key" - exit 1 - ;; -esac - -TARGET_DIR="$HOME/.local/home-automation-backend" -SUPERVISOR_CFG_NAME="home_automation_backend" -APP_NAME="home-automation-backend" -SUPERVISOR_CFG="$SUPERVISOR_CFG_NAME.conf" -BASEDIR=$(dirname "$(realpath "$0")") - -# Install or uninstall based on arguments -install_backend() { - # Installation code here - echo "Installing..." - - sudo supervisorctl stop $SUPERVISOR_CFG_NAME - - mkdir -p $TARGET_DIR - cd $BASEDIR"/../src/" && go build -o $TARGET_DIR/$APP_NAME - - - cp $BASEDIR/"$SUPERVISOR_CFG_NAME"_template.conf $BASEDIR/$SUPERVISOR_CFG - - sed -i "s+command=+command=$TARGET_DIR/$APP_NAME serve+g" $BASEDIR/$SUPERVISOR_CFG - sed -i "s+directory=+directory=$TARGET_DIR+g" $BASEDIR/$SUPERVISOR_CFG - sed -i "s+user=+user=$USER+g" $BASEDIR/$SUPERVISOR_CFG - sed -i "s+group=+group=$USER+g" $BASEDIR/$SUPERVISOR_CFG - sed -i "s+environment=+environment=HOME=\"$HOME\"+g" $BASEDIR/$SUPERVISOR_CFG - - sudo mv $BASEDIR/$SUPERVISOR_CFG /etc/supervisor/conf.d/$SUPERVISOR_CFG - - sudo supervisorctl reread - sudo supervisorctl update - sudo supervisorctl start $SUPERVISOR_CFG_NAME - - echo "Installation complete." -} -uninstall_backend() { - # Uninstallation code here - echo "Uninstalling..." - - sudo supervisorctl stop $SUPERVISOR_CFG_NAME - - sudo supervisorctl remove $SUPERVISOR_CFG_NAME - - sudo rm /etc/supervisor/conf.d/$SUPERVISOR_CFG - - rm -rf $TARGET_DIR/ - - echo "Uninstallation complete." - echo "Config files and db is stored in $HOME/.config/home-automation" -} -update_backend() { - uninstall_backend - install_backend -} - -if [[ $INSTALL ]]; then - install_backend -elif [[ $UNINSTALL ]]; then - uninstall_backend -elif [[ $UPDATE ]]; then - update_backend -else - echo "Invalid argument: $key" - exit 1 -fi \ No newline at end of file diff --git a/legacy/go-backend/src/LICENSE b/legacy/go-backend/src/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/go-backend/src/cmd/root.go b/legacy/go-backend/src/cmd/root.go deleted file mode 100644 index 14f8d34..0000000 --- a/legacy/go-backend/src/cmd/root.go +++ /dev/null @@ -1,41 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu -*/ -package cmd - -import ( - "os" - - "github.com/spf13/cobra" -) - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "home-automation-backend", - Short: "This is the entry point of the home automation backend", - Long: `Home automation backend is a RESTful API server that provides -automation features for may devices.`, - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, -} - -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.home-automation-backend.yaml)") - - // Cobra also supports local flags, which will only run - // when this action is called directly. -} diff --git a/legacy/go-backend/src/cmd/serve.go b/legacy/go-backend/src/cmd/serve.go deleted file mode 100644 index 6cb09b5..0000000 --- a/legacy/go-backend/src/cmd/serve.go +++ /dev/null @@ -1,161 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu -*/ -package cmd - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/go-co-op/gocron/v2" - "github.com/gorilla/mux" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "github.com/t-liu93/home-automation-backend/components/homeassistant" - "github.com/t-liu93/home-automation-backend/components/locationRecorder" - "github.com/t-liu93/home-automation-backend/components/pooRecorder" - "github.com/t-liu93/home-automation-backend/util/notion" - "github.com/t-liu93/home-automation-backend/util/ticktickutil" -) - -var ( - port string - scheduler gocron.Scheduler - ticktick ticktickutil.TicktickUtil - ha *homeassistant.HomeAssistant -) - -// serveCmd represents the serve command -var serveCmd = &cobra.Command{ - Use: "serve", - Short: "Server automation backend", - Run: serve, -} - -func initUtil() { - // init notion - if viper.InConfig("notion.token") { - notion.Init(viper.GetString("notion.token")) - } else { - slog.Error("Notion token not found in config file, exiting..") - os.Exit(1) - } - // init ticktick - ticktick = ticktickutil.Init() -} - -func initComponent() { - // init pooRecorder - pooRecorder.Init(&scheduler) - // init location recorder - locationRecorder.Init() - // init homeassistant - ha = homeassistant.NewHomeAssistant(ticktick) -} - -func serve(cmd *cobra.Command, args []string) { - slog.Info("Starting server..") - - viper.SetConfigName("config") // name of config file (without extension) - viper.SetConfigType("yaml") - viper.AddConfigPath(".") // . is used for dev - viper.AddConfigPath("$HOME/.config/home-automation") - err := viper.ReadInConfig() - if err != nil { - slog.Error(fmt.Sprintf("Cannot read config file, %s, exiting..", err)) - os.Exit(1) - } - viper.WatchConfig() - viper.SetDefault("logLevel", "info") - logLevelCfg := viper.GetString("logLevel") - switch logLevelCfg { - case "debug": - slog.SetLogLoggerLevel(slog.LevelDebug) - case "info": - slog.SetLogLoggerLevel(slog.LevelInfo) - case "warn": - slog.SetLogLoggerLevel(slog.LevelWarn) - case "error": - slog.SetLogLoggerLevel(slog.LevelError) - } - - if viper.InConfig("port") { - port = viper.GetString("port") - } else { - slog.Error("Port not found in config file, exiting..") - os.Exit(1) - } - scheduler, err = gocron.NewScheduler() - defer scheduler.Shutdown() - if err != nil { - slog.Error(fmt.Sprintf("Cannot create scheduler, %s, exiting..", err)) - os.Exit(1) - } - initUtil() - initComponent() - scheduler.Start() - - // routing - router := mux.NewRouter() - router.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("OK")) - }).Methods("GET") - - router.HandleFunc("/poo/latest", pooRecorder.HandleNotifyLatestPoo).Methods("GET") - router.HandleFunc("/poo/record", pooRecorder.HandleRecordPoo).Methods("POST") - router.HandleFunc("/homeassistant/publish", ha.HandleHaMessage).Methods("POST") - - router.HandleFunc("/location/record", locationRecorder.HandleRecordLocation).Methods("POST") - - router.HandleFunc("/ticktick/auth/code", ticktick.HandleAuthCode).Methods("GET") - - srv := &http.Server{ - Addr: ":" + port, - Handler: router, - } - - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - - go func() { - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error(fmt.Sprintf("ListenAndServe error: %v", err)) - os.Exit(1) - } - }() - - slog.Info(fmt.Sprintln("Server started on port", port)) - - <-stop - - slog.Info(fmt.Sprintln("Shutting down the server...")) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - if err := srv.Shutdown(ctx); err != nil { - slog.Error(fmt.Sprintf("Server Shutdown Failed:%+v", err)) - os.Exit(1) - } - slog.Info(fmt.Sprintln("Server gracefully stopped")) -} - -func init() { - rootCmd.AddCommand(serveCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // serveCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") - serveCmd.Flags().StringVarP(&port, "port", "p", "18881", "Port to listen on") -} diff --git a/legacy/go-backend/src/components/homeassistant/homeassistant.go b/legacy/go-backend/src/components/homeassistant/homeassistant.go deleted file mode 100644 index 5630b2f..0000000 --- a/legacy/go-backend/src/components/homeassistant/homeassistant.go +++ /dev/null @@ -1,152 +0,0 @@ -package homeassistant - -import ( - "encoding/json" - "fmt" - "log/slog" - "net/http" - "strings" - "time" - - "github.com/spf13/viper" - "github.com/t-liu93/home-automation-backend/util/ticktickutil" -) - -type haMessage struct { - Target string `json:"target"` - Action string `json:"action"` - Content string `json:"content"` -} - -type HomeAssistant struct { - ticktickUtil ticktickutil.TicktickUtil -} - -type actionTask struct { - Action string `json:"action"` - DueHour int `json:"due_hour"` -} - -func NewHomeAssistant(ticktick ticktickutil.TicktickUtil) *HomeAssistant { - return &HomeAssistant{ - ticktickUtil: ticktick, - } -} - -func (ha *HomeAssistant) HandleHaMessage(w http.ResponseWriter, r *http.Request) { - var message haMessage - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - err := decoder.Decode(&message) - if err != nil { - slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error decoding request body", err)) - http.Error(w, "", http.StatusInternalServerError) - return - } - - switch message.Target { - case "poo_recorder": - res := ha.handlePooRecorderMsg(message) - if !res { - slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling poo recorder message")) - http.Error(w, "", http.StatusInternalServerError) - } - case "location_recorder": - res := ha.handleLocationRecorderMsg(message) - if !res { - slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling location recorder message")) - http.Error(w, "", http.StatusInternalServerError) - } - case "ticktick": - res := ha.handleTicktickMsg(message) - if !res { - slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Error handling ticktick message")) - http.Error(w, "", http.StatusInternalServerError) - } - default: - slog.Warn(fmt.Sprintln("homeassistant.HandleHaMessage: Unknown target", message.Target)) - http.Error(w, "", http.StatusInternalServerError) - } -} - -func (ha *HomeAssistant) handlePooRecorderMsg(message haMessage) bool { - switch message.Action { - case "get_latest": - return ha.handleGetLatestPoo() - default: - slog.Warn(fmt.Sprintln("homeassistant.handlePooRecorderMsg: Unknown action", message.Action)) - return false - } -} - -func (ha *HomeAssistant) handleLocationRecorderMsg(message haMessage) bool { - if message.Action == "record" { - port := viper.GetString("port") - client := &http.Client{ - Timeout: time.Second * 1, - } - _, err := client.Post("http://localhost:"+port+"/location/record", "application/json", strings.NewReader(strings.ReplaceAll(message.Content, "'", "\""))) - if err != nil { - slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Error sending request to location recorder", err)) - return false - } - } else { - slog.Warn(fmt.Sprintln("homeassistant.handleLocationRecorderMsg: Unknown action", message.Action)) - return false - } - return true -} - -func (ha *HomeAssistant) handleTicktickMsg(message haMessage) bool { - switch message.Action { - case "create_action_task": - return ha.createActionTask(message) - default: - slog.Warn(fmt.Sprintln("homeassistant.handleTicktickMsg: Unknown action", message.Action)) - return false - } -} - -func (ha *HomeAssistant) handleGetLatestPoo() bool { - client := &http.Client{ - Timeout: time.Second * 1, - } - port := viper.GetString("port") - _, err := client.Get("http://localhost:" + port + "/poo/latest") - if err != nil { - slog.Warn(fmt.Sprintln("homeassistant.handleGetLatestPoo: Error sending request to poo recorder", err)) - return false - } - - return true -} - -func (ha *HomeAssistant) createActionTask(message haMessage) bool { - if !viper.IsSet("homeassistant.actionTaskProjectId") { - slog.Warn("homeassistant.createActionTask: actionTaskProjectId not found in config file") - return false - } - projectId := viper.GetString("homeassistant.actionTaskProjectId") - detail := strings.ReplaceAll(message.Content, "'", "\"") - var task actionTask - err := json.Unmarshal([]byte(detail), &task) - if err != nil { - slog.Warn(fmt.Sprintln("homeassistant.createActionTask: Error unmarshalling", err)) - return false - } - dueHour := task.DueHour - due := time.Now().Add(time.Hour * time.Duration(dueHour)) - dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1) - dueTicktick := dueNextMidnight.UTC().Format(ticktickutil.DateTimeLayout) - ticktickTask := ticktickutil.Task{ - ProjectId: projectId, - Title: task.Action, - DueDate: dueTicktick, - } - err = ha.ticktickUtil.CreateTask(ticktickTask) - if err != nil { - slog.Warn(fmt.Sprintf("homeassistant.createActionTask: Error creating task %s", err)) - return false - } - return true -} diff --git a/legacy/go-backend/src/components/homeassistant/homeassistant_test.go b/legacy/go-backend/src/components/homeassistant/homeassistant_test.go deleted file mode 100644 index 7b28b22..0000000 --- a/legacy/go-backend/src/components/homeassistant/homeassistant_test.go +++ /dev/null @@ -1,280 +0,0 @@ -package homeassistant - -import ( - "bytes" - "errors" - "log/slog" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/t-liu93/home-automation-backend/util/ticktickutil" -) - -var ( - loggerText = new(bytes.Buffer) -) - -type MockTicktickUtil struct { - mock.Mock -} - -func (m *MockTicktickUtil) HandleAuthCode(w http.ResponseWriter, r *http.Request) { - m.Called(w, r) -} - -func (m *MockTicktickUtil) GetTasks(projectId string) []ticktickutil.Task { - args := m.Called(projectId) - return args.Get(0).([]ticktickutil.Task) -} - -func (m *MockTicktickUtil) HasDuplicateTask(projectId string, taskTitile string) bool { - args := m.Called(projectId, taskTitile) - return args.Bool(0) -} - -func (m *MockTicktickUtil) CreateTask(task ticktickutil.Task) error { - args := m.Called(task) - return args.Error(0) -} - -func SetupTearDown(t *testing.T) (func(), *HomeAssistant) { - loggertearDown := loggerSetupTeardown() - mockTicktick := &MockTicktickUtil{} - ha := NewHomeAssistant(mockTicktick) - - return func() { - loggertearDown() - viper.Reset() - }, ha -} - -func loggerSetupTeardown() func() { - logger := slog.New(slog.NewTextHandler(loggerText, nil)) - defaultLogger := slog.Default() - slog.SetDefault(logger) - - return func() { - slog.SetDefault(defaultLogger) - loggerText.Reset() - } -} - -func TestHandleHaMessageJsonDecodeError(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - invalidRequestBody := ` { "target": "poo_recorder", "action": "get_latest", "content": " }` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody)) - w := httptest.NewRecorder() - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Error decoding request body") -} - -func TestHandlePooRecorderMsgGetLatest(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodGet, r.Method) - assert.Equal(t, "/poo/latest", r.URL.Path) - })) - defer server.Close() - port := strings.Split(server.URL, ":")[2] - viper.Set("port", port) - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Empty(t, loggerText.String()) -} - -func TestHandlePooRecorderMsgUnknownAction(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "poo_recorder", "action": "unknown_action", "content": ""}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.handlePooRecorderMsg: Unknown action") -} - -func TestHandlePooRecorderMsgGetLatestError(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "poo_recorder", "action": "get_latest", "content": ""}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - port := "invalid port" - viper.Set("port", port) - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.handleGetLatestPoo: Error sending request to poo recorder") -} - -func TestHandleLocationRecorderMsg(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "/location/record", r.URL.Path) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - })) - defer server.Close() - - port := strings.Split(server.URL, ":")[2] - viper.Set("port", port) - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Empty(t, loggerText.String()) -} - -func TestHandleLocationRecorderMsgUnknownAction(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "location_recorder", "action": "unknown_action", "content": ""}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Unknown action") -} - -func TestHandleLocationRecorderMsgRequestErr(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "location_recorder", "action": "record", "content": "{'person': 'test', 'latitude': '1.0', 'longitude': '2.0', 'altitude': '3.0'}"}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - port := "invalid port" - viper.Set("port", port) - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.handleLocationRecorderMsg: Error sending request to location recorder") -} - -func TestHandleTicktickMsgCreateActionTask(t *testing.T) { - teardown, _ := SetupTearDown(t) - defer teardown() - const expectedProjectId = "test_project_id" - const dueHour = 12 - due := time.Now().Add(time.Hour * time.Duration(dueHour)) - dueNextMidnight := time.Date(due.Year(), due.Month(), due.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 1) - dueTicktick := dueNextMidnight.UTC().Format(ticktickutil.DateTimeLayout) - - requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - mockTicktick := &MockTicktickUtil{} - mockTicktick.On("CreateTask", mock.Anything).Return(nil) - ha := NewHomeAssistant(mockTicktick) - viper.Set("homeassistant.actionTaskProjectId", expectedProjectId) - ha.HandleHaMessage(w, req) - expectedTask := ticktickutil.Task{ - Title: "test_action", - DueDate: dueTicktick, - ProjectId: expectedProjectId, - } - mockTicktick.AssertCalled(t, "CreateTask", expectedTask) - mockTicktick.AssertNumberOfCalls(t, "CreateTask", 1) - assert.Equal(t, http.StatusOK, w.Code) - assert.Empty(t, loggerText.String()) -} - -func TestHandleTicktickMsgUnknownAction(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "ticktick", "action": "unknown_action", "content": ""}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.handleTicktickMsg: Unknown action") -} - -func TestHandleTicktickMsgProjectIdUnset(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: actionTaskProjectId not found in config file") -} - -func TestHandleTicktickMsgJsonError(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - invalidRequestBody := ` { "target": "ticktick", "action": "create_action_task", "content": "{'title': 'tes, 'action': 'test_action', 'due_hour': 12}"}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(invalidRequestBody)) - w := httptest.NewRecorder() - viper.Set("homeassistant.actionTaskProjectId", "some project id") - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error unmarshalling") -} - -func TestHandleTicktickMsgTicktickUtilErr(t *testing.T) { - teardown, _ := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "ticktick", "action": "create_action_task", "content": "{'title': 'test', 'action': 'test_action', 'due_hour': 12}"}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - mockedTicktickUtil := &MockTicktickUtil{} - viper.Set("homeassistant.actionTaskProjectId", "some project id") - - mockedTicktickUtil.On("CreateTask", mock.Anything).Return(errors.New("some error")) - - ha := NewHomeAssistant(mockedTicktickUtil) - - ha.HandleHaMessage(w, req) - - mockedTicktickUtil.AssertCalled(t, "CreateTask", mock.Anything) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.createActionTask: Error creating task") -} - -func TestHandleHaMessageUnknownTarget(t *testing.T) { - teardown, ha := SetupTearDown(t) - defer teardown() - - requestBody := `{"target": "unknown_target", "action": "record", "content": ""}` - req := httptest.NewRequest(http.MethodPost, "/homeassistant/publish", strings.NewReader(requestBody)) - w := httptest.NewRecorder() - - ha.HandleHaMessage(w, req) - assert.Equal(t, http.StatusInternalServerError, w.Code) - assert.Contains(t, loggerText.String(), "homeassistant.HandleHaMessage: Unknown target") -} diff --git a/legacy/go-backend/src/components/locationRecorder/locationRecorder.go b/legacy/go-backend/src/components/locationRecorder/locationRecorder.go deleted file mode 100644 index c88def6..0000000 --- a/legacy/go-backend/src/components/locationRecorder/locationRecorder.go +++ /dev/null @@ -1,194 +0,0 @@ -package locationRecorder - -import ( - "database/sql" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "strconv" - "time" - - "github.com/spf13/viper" -) - -var ( - db *sql.DB -) - -const ( - currentDBVersion = 2 -) - -type Location struct { - Person string `json:"person"` - DateTime string `json:"datetime"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Altitude sql.NullFloat64 `json:"altitude,omitempty"` -} - -type LocationContent struct { - Person string `json:"person"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` - Altitude string `json:"altitude,omitempty"` -} - -func Init() { - initDb() -} - -func HandleRecordLocation(w http.ResponseWriter, r *http.Request) { - var location LocationContent - - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - err := decoder.Decode(&location) - if err != nil { - slog.Warn(fmt.Sprintln("HandleRecordLocation Error decoding request body", err)) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - latiF64, _ := strconv.ParseFloat(location.Latitude, 64) - longiF64, _ := strconv.ParseFloat(location.Longitude, 64) - altiF64, _ := strconv.ParseFloat(location.Altitude, 64) - InsertLocationNow(location.Person, latiF64, longiF64, altiF64) -} - -func InsertLocation(person string, datetime time.Time, latitude float64, longitude float64, altitude float64) { - _, err := db.Exec(`INSERT OR IGNORE INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, - person, datetime.UTC().Format(time.RFC3339), latitude, longitude, altitude) - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorder.InsertLocation Error inserting location", err)) - } -} - -func InsertLocationNow(person string, latitude float64, longitude float64, altitude float64) { - InsertLocation(person, time.Now(), latitude, longitude, altitude) -} - -func initDb() { - if !viper.InConfig("locationRecorder.dbPath") { - slog.Info("LocationRecorderInit dbPath not found in config file, using default: location_recorder.db") - viper.SetDefault("locationRecorder.dbPath", "location_recorder.db") - } - - dbPath := viper.GetString("locationRecorder.dbPath") - err := error(nil) - db, err = sql.Open("sqlite", dbPath) - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit Error opening database", err)) - os.Exit(1) - } - err = db.Ping() - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit Error pinging database", err)) - os.Exit(1) - } - migrateDb() -} - -func migrateDb() { - var userVersion int - err := db.QueryRow("PRAGMA user_version").Scan(&userVersion) - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit Error getting db user version", err)) - os.Exit(1) - } - if userVersion == 0 { - migrateDb0To1(&userVersion) - } - if userVersion == 1 { - migrateDb1To2(&userVersion) - } - if userVersion != currentDBVersion { - slog.Error(fmt.Sprintln("LocationRecorderInit Error unsupported database version", userVersion)) - os.Exit(1) - } -} - -func migrateDb0To1(userVersion *int) { - // this is actually create new db - slog.Info("Creating location recorder database version 1..") - _, err := db.Exec(`CREATE TABLE IF NOT EXISTS location ( - person TEXT NOT NULL, - datetime TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - altitude REAL, - PRIMARY KEY (person, datetime))`) - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error creating table", err)) - os.Exit(1) - } - _, err = db.Exec(`PRAGMA user_version = 1`) - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit DB0To1 Error setting user version to 1", err)) - os.Exit(1) - } - *userVersion = 1 -} - -func migrateDb1To2(userVersion *int) { - // this will change the datetime format into Real RFC3339 - slog.Info("Migrating location recorder database version 1 to 2..") - dbTx, err := db.Begin() - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit DB1To2 Error beginning transaction", err)) - os.Exit(1) - } - fail := func(err error, step string) { - slog.Error(fmt.Sprintf("LocationRecorderInit DB1To2 Error %s: %s", step, err)) - dbTx.Rollback() - os.Exit(1) - } - _, err = dbTx.Exec(`ALTER TABLE location RENAME TO location_old`) - if err != nil { - fail(err, "renaming table") - } - _, err = dbTx.Exec(`CREATE TABLE IF NOT EXISTS location ( - person TEXT NOT NULL, - datetime TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - altitude REAL, - PRIMARY KEY (person, datetime))`) - if err != nil { - fail(err, "creating new table") - } - row, err := dbTx.Query(`SELECT person, datetime, latitude, longitude, altitude FROM location_old`) - if err != nil { - fail(err, "selecting from old table") - } - defer row.Close() - for row.Next() { - var location Location - err = row.Scan(&location.Person, &location.DateTime, &location.Latitude, &location.Longitude, &location.Altitude) - if err != nil { - fail(err, "scanning row") - } - dateTime, err := time.Parse("2006-01-02T15:04:05-0700", location.DateTime) - if err != nil { - fail(err, "parsing datetime") - } - _, err = dbTx.Exec(`INSERT INTO location (person, datetime, latitude, longitude, altitude) VALUES (?, ?, ?, ?, ?)`, location.Person, dateTime.UTC().Format(time.RFC3339), location.Latitude, location.Longitude, location.Altitude) - if err != nil { - fail(err, "inserting new row") - } - } - - _, err = dbTx.Exec(`DROP TABLE location_old`) - if err != nil { - fail(err, "dropping old table") - } - - _, err = dbTx.Exec(`PRAGMA user_version = 2`) - if err != nil { - slog.Error(fmt.Sprintln("LocationRecorderInit Error setting user version to 2", err)) - os.Exit(1) - } - dbTx.Commit() - *userVersion = 2 -} diff --git a/legacy/go-backend/src/components/pooRecorder/pooRecorder.go b/legacy/go-backend/src/components/pooRecorder/pooRecorder.go deleted file mode 100644 index 3dd4bf6..0000000 --- a/legacy/go-backend/src/components/pooRecorder/pooRecorder.go +++ /dev/null @@ -1,366 +0,0 @@ -package pooRecorder - -import ( - "database/sql" - "encoding/json" - "fmt" - "net/http" - "os" - "strconv" - "strings" - "time" - - "log/slog" - - "github.com/go-co-op/gocron/v2" - "github.com/jomei/notionapi" - "github.com/spf13/viper" - "github.com/t-liu93/home-automation-backend/util/homeassistantutil" - "github.com/t-liu93/home-automation-backend/util/notion" - _ "modernc.org/sqlite" -) - -var ( - db *sql.DB - scheduler *gocron.Scheduler -) - -type recordDetail struct { - Status string `json:"status"` - Latitude string `json:"latitude"` - Longitude string `json:"longitude"` -} - -type pooStatusSensorAttributes struct { - LastPoo string `json:"last_poo"` - FriendlyName string `json:"friendly_name,"` -} - -type pooStatusWebhookBody struct { - Status string `json:"status"` -} - -type pooStatusDbEntry struct { - Timestamp string - Status string - Latitude float64 - Longitude float64 -} - -func Init(mainScheduler *gocron.Scheduler) { - initDb() - initScheduler(mainScheduler) - notionDbSync() - publishLatestPooSensor() -} - -func HandleRecordPoo(w http.ResponseWriter, r *http.Request) { - var record recordDetail - if !viper.InConfig("pooRecorder.tableId") { - slog.Warn("HandleRecordPoo Table ID not found in config file") - http.Error(w, "Table ID not found in config file", http.StatusInternalServerError) - return - } - decoder := json.NewDecoder(r.Body) - decoder.DisallowUnknownFields() - err := decoder.Decode(&record) - if err != nil { - slog.Warn(fmt.Sprintln("HandleRecordPoo Error decoding request body", err)) - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - now := time.Now() - err = storeStatus(record, now) - if err != nil { - slog.Warn(fmt.Sprintln("HandleRecordPoo Error storing status", err)) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - publishLatestPooSensor() - if viper.InConfig("pooRecorder.webhookId") { - homeassistantutil.TriggerWebhook(viper.GetString("pooRecorder.webhookId"), pooStatusWebhookBody{Status: record.Status}) - } else { - slog.Warn("HandleRecordPoo Webhook ID not found in config file") - } -} - -func HandleNotifyLatestPoo(w http.ResponseWriter, r *http.Request) { - err := publishLatestPooSensor() - if err != nil { - slog.Warn(fmt.Sprintln("HandleNotifyLatestPoo Error publishing latest poo", err)) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - slog.Debug(fmt.Sprintln("HandleGetLatestPoo Latest poo")) -} - -func publishLatestPooSensor() error { - var latest pooStatusDbEntry - err := db.QueryRow(`SELECT timestamp, status, latitude, longitude FROM poo_records ORDER BY timestamp DESC LIMIT 1`).Scan(&latest.Timestamp, &latest.Status, &latest.Latitude, &latest.Longitude) - if err != nil { - slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error getting latest poo", err)) - return err - } - recordTime, err := time.Parse("2006-01-02T15:04Z07:00", latest.Timestamp) - if err != nil { - slog.Warn(fmt.Sprintln("HandleGetLatestPoo Error parsing timestamp", err)) - return err - } - viper.SetDefault("pooRecorder.sensorEntityName", "sensor.test_poo_status") - viper.SetDefault("pooRecorder.sensorFriendlyName", "Poo Status") - sensorEntityName := viper.GetString("pooRecorder.sensorEntityName") - sensorFriendlyName := viper.GetString("pooRecorder.sensorFriendlyName") - recordTime = recordTime.Local() - pooStatus := homeassistantutil.HttpSensor{ - EntityId: sensorEntityName, - State: latest.Status, - Attributes: pooStatusSensorAttributes{ - LastPoo: recordTime.Format("Mon | 2006-01-02 | 15:04"), - FriendlyName: sensorFriendlyName, - }, - } - homeassistantutil.PublishSensor(pooStatus) - return nil -} - -func initDb() { - if !viper.InConfig("pooRecorder.dbPath") { - slog.Info("PooRecorderInit dbPath not found in config file, using default: pooRecorder.db") - viper.SetDefault("pooRecorder.dbPath", "pooRecorder.db") - } - - dbPath := viper.GetString("pooRecorder.dbPath") - err := error(nil) - db, err = sql.Open("sqlite", dbPath) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderInit Error opening database", err)) - os.Exit(1) - } - err = db.Ping() - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderInit Error pinging database", err)) - os.Exit(1) - } - migrateDb() -} - -func migrateDb() { - var userVersion int - err := db.QueryRow("PRAGMA user_version").Scan(&userVersion) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderInit Error getting db user version", err)) - os.Exit(1) - } - if userVersion == 0 { - migrateDb0To1(&userVersion) - } -} - -func migrateDb0To1(userVersion *int) { - // this is actually create new db - slog.Info("Creating database version 1..") - _, err := db.Exec(`CREATE TABLE IF NOT EXISTS poo_records ( - timestamp TEXT NOT NULL, - status TEXT NOT NULL, - latitude REAL NOT NULL, - longitude REAL NOT NULL, - PRIMARY KEY (timestamp))`) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderInit Error creating table", err)) - os.Exit(1) - } - _, err = db.Exec(`PRAGMA user_version = 1`) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderInit Error setting user version to 1", err)) - os.Exit(1) - } - *userVersion = 1 -} - -func initScheduler(mainScheduler *gocron.Scheduler) { - scheduler = mainScheduler - _, err := (*scheduler).NewJob(gocron.CronJob("0 5 * * *", false), gocron.NewTask( - notionDbSync, - )) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderInit Error creating scheduled task", err)) - os.Exit(1) - } -} - -func notionDbSync() { - slog.Info("PooRecorder Running DB sync with Notion..") - if !viper.InConfig("pooRecorder.tableId") { - slog.Warn("PooRecorder Table ID not found in config file, sync aborted") - return - } - tableId := viper.GetString("pooRecorder.tableId") - rowsNotion, err := notion.GetAllTableRows(tableId) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get table header", err)) - return - } - header := rowsNotion[0] - rowsNotion = rowsNotion[1:] // remove header - rowsDb, err := db.Query(`SELECT * FROM poo_records`) - rowsDbMap := make(map[string]pooStatusDbEntry) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err)) - return - } - defer rowsDb.Close() - for rowsDb.Next() { - var row pooStatusDbEntry - err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err)) - return - } - rowsDbMap[row.Timestamp] = row - } - // notion to db - syncNotionToDb(rowsNotion, rowsDbMap) - - // db to notion - syncDbToNotion(header.GetID().String(), tableId, rowsNotion) - -} - -func syncNotionToDb(rowsNotion []notionapi.TableRowBlock, rowsDbMap map[string]pooStatusDbEntry) { - counter := 0 - for _, rowNotion := range rowsNotion { - rowNotionTimestamp := rowNotion.TableRow.Cells[0][0].PlainText + "T" + rowNotion.TableRow.Cells[1][0].PlainText - rowNotionTime, err := time.ParseInLocation("2006-01-02T15:04", rowNotionTimestamp, time.Now().Location()) - if err != nil { - slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse timestamp", err)) - return - } - rowNotionTimeInDbFormat := rowNotionTime.UTC().Format("2006-01-02T15:04Z07:00") - _, exists := rowsDbMap[rowNotionTimeInDbFormat] - if !exists { - locationNotion := rowNotion.TableRow.Cells[3][0].PlainText - latitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[0], 64) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse latitude to float", err)) - return - } - longitude, err := strconv.ParseFloat(strings.Split(locationNotion, ",")[1], 64) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to parse longitude to float", err)) - return - } - _, err = db.Exec(`INSERT INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`, - rowNotionTimeInDbFormat, rowNotion.TableRow.Cells[2][0].PlainText, latitude, longitude) - if err != nil { - slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to insert new row", err)) - return - } - counter++ - } - } - slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from Notion to DB")) -} - -func syncDbToNotion(headerId string, tableId string, rowsNotion []notionapi.TableRowBlock) { - counter := 0 - var rowsDbSlice []pooStatusDbEntry - rowsDb, err := db.Query(`SELECT * FROM poo_records ORDER BY timestamp DESC`) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to get db rows", err)) - return - } - defer rowsDb.Close() - for rowsDb.Next() { - var row pooStatusDbEntry - err = rowsDb.Scan(&row.Timestamp, &row.Status, &row.Latitude, &row.Longitude) - if err != nil { - slog.Error(fmt.Sprintln("PooRecorderSyncDb Failed to scan db row", err)) - return - } - rowsDbSlice = append(rowsDbSlice, row) - } - startFromId := headerId - for iNotion, iDb := 0, 0; iNotion < len(rowsNotion) && iDb < len(rowsDbSlice); { - notionTimeStamp := rowsNotion[iNotion].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion].TableRow.Cells[1][0].PlainText - notionTime, err := time.ParseInLocation("2006-01-02T15:04", notionTimeStamp, time.Now().Location()) - if err != nil { - slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse notion timestamp", err)) - return - } - notionTimeStampInDbFormat := notionTime.UTC().Format("2006-01-02T15:04Z07:00") - dbTimeStamp := rowsDbSlice[iDb].Timestamp - dbTime, err := time.Parse("2006-01-02T15:04Z07:00", dbTimeStamp) - if err != nil { - slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse db timestamp", err)) - return - } - dbTimeLocal := dbTime.Local() - dbTimeDate := dbTimeLocal.Format("2006-01-02") - dbTimeTime := dbTimeLocal.Format("15:04") - if notionTimeStampInDbFormat == dbTimeStamp { - startFromId = rowsNotion[iNotion].GetID().String() - iNotion++ - iDb++ - continue - } - if iNotion != len(rowsNotion)-1 { - notionNextTimeStamp := rowsNotion[iNotion+1].TableRow.Cells[0][0].PlainText + "T" + rowsNotion[iNotion+1].TableRow.Cells[1][0].PlainText - notionNextTime, err := time.ParseInLocation("2006-01-02T15:04", notionNextTimeStamp, time.Now().Location()) - if err != nil { - slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to parse next notion timestamp", err)) - return - } - if notionNextTime.After(notionTime) { - slog.Error(fmt.Sprintf("PooRecorderSyncDb Notion timestamp %s is after next timestamp %s, checking, aborting", notionTimeStamp, notionNextTimeStamp)) - return - } - } - id, err := notion.WriteTableRow([]string{ - dbTimeDate, - dbTimeTime, - rowsDbSlice[iDb].Status, - fmt.Sprintf("%s,%s", - strconv.FormatFloat(rowsDbSlice[iDb].Latitude, 'f', -1, 64), - strconv.FormatFloat(rowsDbSlice[iDb].Longitude, 'f', -1, 64))}, - tableId, - startFromId) - if err != nil { - slog.Warn(fmt.Sprintln("PooRecorderSyncDb Failed to write row to Notion", err)) - return - } - startFromId = id - iDb++ - counter++ - time.Sleep(400 * time.Millisecond) - } - slog.Info(fmt.Sprintln("PooRecorderSyncDb Inserted", counter, "new rows from DB to Notion")) -} - -func storeStatus(record recordDetail, timestamp time.Time) error { - tableId := viper.GetString("pooRecorder.tableId") - recordDate := timestamp.Format("2006-01-02") - recordTime := timestamp.Format("15:04") - slog.Debug(fmt.Sprintln("Recording poo", record.Status, "at", record.Latitude, record.Longitude)) - _, err := db.Exec(`INSERT OR IGNORE INTO poo_records (timestamp, status, latitude, longitude) VALUES (?, ?, ?, ?)`, - timestamp.UTC().Format("2006-01-02T15:04Z07:00"), record.Status, record.Latitude, record.Longitude) - if err != nil { - return err - } - go func() { - header, err := notion.GetTableRows(tableId, 1, "") - if err != nil { - slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to get table header", err)) - return - } - if len(header) == 0 { - slog.Warn("HandleRecordPoo Table header not found") - return - } - headerId := header[0].GetID() - _, err = notion.WriteTableRow([]string{recordDate, recordTime, record.Status, record.Latitude + "," + record.Longitude}, tableId, headerId.String()) - if err != nil { - slog.Warn(fmt.Sprintln("HandleRecordPoo Failed to write table row", err)) - } - }() - return nil -} diff --git a/legacy/go-backend/src/go.mod b/legacy/go-backend/src/go.mod deleted file mode 100644 index 33aa41a..0000000 --- a/legacy/go-backend/src/go.mod +++ /dev/null @@ -1,54 +0,0 @@ -module github.com/t-liu93/home-automation-backend - -go 1.23.0 - -require ( - github.com/go-co-op/gocron/v2 v2.11.0 - github.com/gorilla/mux v1.8.1 - github.com/jomei/notionapi v1.13.2 - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.10.0 - golang.org/x/term v0.24.0 - modernc.org/sqlite v1.33.1 -) - -require ( - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jonboulle/clockwork v0.4.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.2 // indirect - github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.14.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/strutil v1.2.0 // indirect - modernc.org/token v1.1.0 // indirect -) diff --git a/legacy/go-backend/src/go.sum b/legacy/go-backend/src/go.sum deleted file mode 100644 index 27cceef..0000000 --- a/legacy/go-backend/src/go.sum +++ /dev/null @@ -1,140 +0,0 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE= -github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jomei/notionapi v1.13.2 h1:YpHKNpkoTMlUfWTlVIodOmQDgRKjfwmtSNVa6/6yC9E= -github.com/jomei/notionapi v1.13.2/go.mod h1:BqzP6JBddpBnXvMSIxiR5dCoCjKngmz5QNl1ONDlDoM= -github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4= -github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= -github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= -github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= -golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= -golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM= -modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/legacy/go-backend/src/helper/location_recorder/LICENSE b/legacy/go-backend/src/helper/location_recorder/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go b/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go deleted file mode 100644 index f9bdbb1..0000000 --- a/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go +++ /dev/null @@ -1,40 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu - -*/ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -// addgpxCmd represents the addgpx command -var addgpxCmd = &cobra.Command{ - Use: "addgpx", - Short: "A brief description of your command", - Long: `A longer description that spans multiple lines and likely contains examples -and usage of using your command. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("addgpx called") - }, -} - -func init() { - rootCmd.AddCommand(addgpxCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // addgpxCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // addgpxCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} diff --git a/legacy/go-backend/src/helper/location_recorder/cmd/root.go b/legacy/go-backend/src/helper/location_recorder/cmd/root.go deleted file mode 100644 index 57b09c7..0000000 --- a/legacy/go-backend/src/helper/location_recorder/cmd/root.go +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu - -*/ -package cmd - -import ( - "os" - - "github.com/spf13/cobra" -) - - - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "location_recorder", - Short: "A brief description of your application", - Long: `A longer description that spans multiple lines and likely contains -examples and usage of using your application. For example: - -Cobra is a CLI library for Go that empowers applications. -This application is a tool to generate the needed files -to quickly create a Cobra application.`, - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, -} - -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.location_recorder.yaml)") - - // Cobra also supports local flags, which will only run - // when this action is called directly. - rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} - - diff --git a/legacy/go-backend/src/helper/location_recorder/main.go b/legacy/go-backend/src/helper/location_recorder/main.go deleted file mode 100644 index 64f4648..0000000 --- a/legacy/go-backend/src/helper/location_recorder/main.go +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu - -*/ -package main - -import "github.com/t-liu93/home-automation-backend/helper/location_recorder/cmd" - -func main() { - cmd.Execute() -} diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/LICENSE b/legacy/go-backend/src/helper/poo_recorder_helper/LICENSE deleted file mode 100644 index e69de29..0000000 diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go deleted file mode 100644 index 2c03006..0000000 --- a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go +++ /dev/null @@ -1,127 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu -*/ -package cmd - -import ( - "context" - "fmt" - "log" - "os" - "time" - - "github.com/jomei/notionapi" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var notionToken string -var notionTableId string - -// reverseCmd represents the reverse command -var reverseCmd = &cobra.Command{ - Use: "reverse", - Short: "Reverse given poo recording table", - Long: `Reverse the given poo recording table. Provide the Notion API token and the table ID to reverse. - The Notion API token can be obtained from https://www.notion.so/my-integrations. The table ID can be obtained from the URL of the table. - The token and table ID will be input in the following prompt. - `, - Run: readCredentials, -} - -func readCredentials(cmd *cobra.Command, args []string) { - if notionToken == "" || notionTableId == "" { - fmt.Print("Enter Notion API token: ") - pw, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - log.Fatalf("failed to read NOTION API Token: %v", err) - } - notionToken = string(pw) - fmt.Print("\nEnter Notion table ID: ") - tableId, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - log.Fatalf("failed to read NOTION table ID: %v", err) - } - notionTableId = string(tableId) - } - reverseRun() -} - -func reverseRun() { - client := notionapi.NewClient(notionapi.Token(notionToken)) - rows := []notionapi.Block{} - fmt.Println("Reverse table ID: ", notionTableId) - block, err := client.Block.Get(context.Background(), notionapi.BlockID(notionTableId)) - if err != nil { - log.Fatalf("Failed to get table detail: %v", err) - } - if block.GetType().String() != "table" { - log.Fatalf("Block ID %s is not a table", notionTableId) - } - headerBlock, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), ¬ionapi.Pagination{ - StartCursor: "", - PageSize: 100, - }) - headerId := headerBlock.Results[0].GetID() - nextCursor := headerId.String() - hasMore := true - for hasMore { - blockChildren, _ := client.Block.GetChildren(context.Background(), notionapi.BlockID(notionTableId), ¬ionapi.Pagination{ - StartCursor: notionapi.Cursor(nextCursor), - PageSize: 100, - }) - rows = append(rows, blockChildren.Results...) - hasMore = blockChildren.HasMore - nextCursor = blockChildren.NextCursor - } - rows = rows[1:] - rowsR := reverseTable(rows) - nrRowsToDelete := len(rowsR) - for index, row := range rowsR { - client.Block.Delete(context.Background(), row.GetID()) - if index%10 == 0 || index == nrRowsToDelete-1 { - fmt.Printf("Deleted %d/%d rows\n", index, nrRowsToDelete) - } - time.Sleep(400 * time.Millisecond) - } - after := headerId - fmt.Println("Writing rows back to table") - for len(rowsR) > 0 { - var rowsToWrite []notionapi.Block - if len(rowsR) > 100 { - rowsToWrite = rowsR[:100] - } else { - rowsToWrite = rowsR - } - client.Block.AppendChildren(context.Background(), notionapi.BlockID(notionTableId), ¬ionapi.AppendBlockChildrenRequest{ - After: after, - Children: rowsToWrite, - }) - after = rowsToWrite[len(rowsToWrite)-1].GetID() - rowsR = rowsR[len(rowsToWrite):] - } - -} - -func reverseTable[T any](rows []T) []T { - for i, j := 0, len(rows)-1; i < j; i, j = i+1, j-1 { - rows[i], rows[j] = rows[j], rows[i] - } - return rows -} - -func init() { - rootCmd.AddCommand(reverseCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // reverseCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // reverseCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") - reverseCmd.Flags().StringVar(¬ionToken, "token", "", "Notion API token") - reverseCmd.Flags().StringVar(¬ionTableId, "table-id", "", "Notion table id to reverse") -} diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go deleted file mode 100644 index a681b70..0000000 --- a/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu -*/ -package cmd - -import ( - "os" - - "github.com/spf13/cobra" -) - -// rootCmd represents the base command when called without any subcommands -var rootCmd = &cobra.Command{ - Use: "poo_recorder_helper", - Short: "Poo recorder helper executables.", - // Uncomment the following line if your bare application - // has an action associated with it: - // Run: func(cmd *cobra.Command, args []string) { }, -} - -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } -} - -func init() { - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. - - // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.poo_recorder_helper.yaml)") - - // Cobra also supports local flags, which will only run - // when this action is called directly. -} diff --git a/legacy/go-backend/src/helper/poo_recorder_helper/main.go b/legacy/go-backend/src/helper/poo_recorder_helper/main.go deleted file mode 100644 index 14e2910..0000000 --- a/legacy/go-backend/src/helper/poo_recorder_helper/main.go +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu - -*/ -package main - -import "github.com/t-liu93/home-automation-backend/helper/poo_recorder_helper/cmd" - -func main() { - cmd.Execute() -} diff --git a/legacy/go-backend/src/main.go b/legacy/go-backend/src/main.go deleted file mode 100644 index a87c15d..0000000 --- a/legacy/go-backend/src/main.go +++ /dev/null @@ -1,11 +0,0 @@ -/* -Copyright © 2024 Tianyu Liu - -*/ -package main - -import "github.com/t-liu93/home-automation-backend/cmd" - -func main() { - cmd.Execute() -} diff --git a/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go b/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go deleted file mode 100644 index 280c5e8..0000000 --- a/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go +++ /dev/null @@ -1,96 +0,0 @@ -package homeassistantutil - -import ( - "bytes" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "time" - - "github.com/spf13/viper" -) - -const ( - ipField string = "homeassistant.ip" - portField string = "homeassistant.port" - authTokenField string = "homeassistant.authToken" - webhookPath string = "/api/webhook/" - sensorPath string = "/api/states/" -) - -type HttpSensor struct { - EntityId string `json:"entity_id"` - State string `json:"state"` - Attributes interface{} `json:"attributes"` -} - -type WebhookBody interface{} - -func TriggerWebhook(webhookId string, body WebhookBody) { - if viper.InConfig(ipField) && - viper.InConfig(portField) && - viper.InConfig(authTokenField) { - url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), webhookPath, webhookId) - payload, err := json.Marshal(body) - if err != nil { - slog.Warn(fmt.Sprintln("TriggerWebhook Error marshalling", err)) - return - } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) - if err != nil { - slog.Warn(fmt.Sprintln("TriggerWebhook Error creating request", err)) - return - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField)) - client := &http.Client{ - Timeout: time.Second * 1, - } - go func() { - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("TriggerWebhook Error sending request", err)) - } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - slog.Warn(fmt.Sprintln("TriggerWebhook Unexpected response status", resp.StatusCode)) - } - defer resp.Body.Close() - }() - } else { - slog.Warn("TriggerWebhook Home Assistant IP, port, or token not found in config file") - } -} - -func PublishSensor(sensor HttpSensor) { - if viper.InConfig(ipField) && - viper.InConfig(portField) && - viper.InConfig(authTokenField) { - url := fmt.Sprintf("http://%s:%s%s%s", viper.GetString(ipField), viper.GetString(portField), sensorPath, sensor.EntityId) - payload, err := json.Marshal(sensor) - if err != nil { - slog.Warn(fmt.Sprintln("PublishSensor Error marshalling", err)) - return - } - req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) - if err != nil { - slog.Warn(fmt.Sprintln("PublishSensor Error creating request", err)) - return - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+viper.GetString(authTokenField)) - client := &http.Client{ - Timeout: time.Second * 1, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("PublishSensor Error sending request", err)) - } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - slog.Warn(fmt.Sprintln("PublishSensor Unexpected response status", resp.StatusCode)) - } - defer resp.Body.Close() - } else { - slog.Warn("PublishSensor Home Assistant IP, port, or token not found in config file") - } -} diff --git a/legacy/go-backend/src/util/notion/notion.go b/legacy/go-backend/src/util/notion/notion.go deleted file mode 100644 index 5076ef7..0000000 --- a/legacy/go-backend/src/util/notion/notion.go +++ /dev/null @@ -1,129 +0,0 @@ -package notion - -import ( - "context" - "errors" - "fmt" - "log/slog" - - "github.com/jomei/notionapi" -) - -var client *notionapi.Client - -func Init(token string) { - client = notionapi.NewClient(notionapi.Token(token)) -} - -func GetClient() *notionapi.Client { - return client -} - -func GetTableRows(tableId string, numberOfRows int, startFromId string) ([]notionapi.TableRowBlock, error) { - if client == nil { - return nil, errors.New("notion client not initialized") - } - var rows []notionapi.TableRowBlock - var nextNumberToGet int - if numberOfRows > 100 { - nextNumberToGet = 100 - } else { - nextNumberToGet = numberOfRows - } - for numberOfRows > 0 { - block, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(tableId), ¬ionapi.Pagination{ - StartCursor: notionapi.Cursor(startFromId), - PageSize: nextNumberToGet, - }) - if err != nil { - return nil, err - } - for _, block := range block.Results { - if block.GetType().String() == "table_row" { - tableRow, ok := block.(*notionapi.TableRowBlock) - if !ok { - slog.Error("Notion.GetTableRows Failed to cast block to table row") - return nil, errors.New("Notion.GetTableRows failed to cast block to table row") - } - rows = append(rows, *tableRow) - } else { - slog.Error(fmt.Sprintf("Block ID %s is not a table row", block.GetID())) - return nil, errors.New("Notion.GetAllTableRows block ID is not a table row") - } - } - numberOfRows -= nextNumberToGet - if numberOfRows > 100 { - nextNumberToGet = 100 - } else { - nextNumberToGet = numberOfRows - } - } - return rows, nil -} - -func GetAllTableRows(tableId string) ([]notionapi.TableRowBlock, error) { - if client == nil { - return nil, errors.New("notion client not initialized") - } - rows := []notionapi.TableRowBlock{} - nextCursor := "" - hasMore := true - for hasMore { - blockChildren, err := client.Block.GetChildren(context.Background(), notionapi.BlockID(tableId), ¬ionapi.Pagination{ - StartCursor: notionapi.Cursor(nextCursor), - PageSize: 100, - }) - if err != nil { - return nil, err - } - for _, block := range blockChildren.Results { - if block.GetType().String() == "table_row" { - tableRow, ok := block.(*notionapi.TableRowBlock) - if !ok { - slog.Error("Notion.GetAllTableRows Failed to cast block to table row") - return nil, errors.New("Notion.GetAllTableRows failed to cast block to table row") - } - rows = append(rows, *tableRow) - } else { - slog.Error(fmt.Sprintf("Block ID %s is not a table row", block.GetID())) - return nil, errors.New("Notion.GetAllTableRows block ID is not a table row") - } - } - nextCursor = blockChildren.NextCursor - hasMore = blockChildren.HasMore - } - return rows, nil -} - -func WriteTableRow(content []string, tableId string, after string) (string, error) { - if client == nil { - return "", errors.New("notion client not initialized") - } - rich := [][]notionapi.RichText{} - for _, c := range content { - rich = append(rich, []notionapi.RichText{ - { - Type: "text", - Text: ¬ionapi.Text{ - Content: c, - }, - }, - }) - } - tableRow := notionapi.TableRowBlock{ - BasicBlock: notionapi.BasicBlock{ - Object: "block", - Type: "table_row", - }, - TableRow: notionapi.TableRow{ - Cells: rich, - }, - } - - res, err := client.Block.AppendChildren(context.Background(), notionapi.BlockID(tableId), ¬ionapi.AppendBlockChildrenRequest{ - After: notionapi.BlockID(after), - Children: []notionapi.Block{tableRow}, - }) - - return res.Results[0].GetID().String(), err -} diff --git a/legacy/go-backend/src/util/ticktickutil/ticktickutil.go b/legacy/go-backend/src/util/ticktickutil/ticktickutil.go deleted file mode 100644 index 23b1627..0000000 --- a/legacy/go-backend/src/util/ticktickutil/ticktickutil.go +++ /dev/null @@ -1,297 +0,0 @@ -package ticktickutil - -import ( - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "time" - - "github.com/spf13/viper" -) - -const ( - DateTimeLayout = "2006-01-02T15:04:05-0700" -) - -type ( - TicktickUtil interface { - HandleAuthCode(w http.ResponseWriter, r *http.Request) - GetTasks(projectId string) []Task - HasDuplicateTask(projectId string, taskTitile string) bool - CreateTask(task Task) error - } - - TicktickUtilImpl struct { - authState string - } -) - -type ( - Project struct { - Id string `json:"id"` - Name string `json:"name"` - Color string `json:"color,omitempty"` - SortOrder int64 `json:"sortOrder,omitempty"` - Closed bool `json:"closed,omitempty"` - GroupId string `json:"groupId,omitempty"` - ViewMode string `json:"viewMode,omitempty"` - Permission string `json:"permission,omitempty"` - Kind string `json:"kind,omitempty"` - } - - Column struct { - Id string `json:"id"` - Name string `json:"name"` - ProjectId string `json:"projectId"` - SortOrder int64 `json:"sortOrder,omitempty"` - } - - Task struct { - Id string `json:"id"` - ProjectId string `json:"projectId"` - Title string `json:"title"` - IsAllDay bool `json:"isAllDay,omitempty"` - CompletedTime string `json:"completedTime,omitempty"` - Content string `json:"content,omitempty"` - Desc string `json:"desc,omitempty"` - DueDate string `json:"dueDate,omitempty"` - Items []interface{} `json:"items,omitempty"` - Priority int `json:"priority,omitempty"` - Reminders []string `json:"reminders,omitempty"` - RepeatFlag string `json:"repeatFlag,omitempty"` - SortOrder int64 `json:"sortOrder,omitempty"` - StartDate string `json:"startDate,omitempty"` - Status int32 `json:"status,omitempty"` - TimeZone string `json:"timeZone,omitempty"` - } - - ProjectData struct { - Project Project `json:"project"` - Tasks []Task `json:"tasks"` - Columns []Column `json:"columns,omitempty"` - } -) - -func Init() TicktickUtil { // TODO: Will modify Init to a proper behavior - ticktickUtilImpl := &TicktickUtilImpl{} - if !viper.InConfig("ticktick.clientId") { - slog.Error("TickTick clientId not found in config file, exiting..") - os.Exit(1) - } - if !viper.InConfig("ticktick.clientSecret") { - slog.Error("TickTick clientSecret not found in config file, exiting..") - os.Exit(1) - } - if viper.InConfig("ticktick.token") { - _, err := getProjects() - if err != nil { - if err.Error() == "error response from TickTick: 401 Unauthorized" { - } - } - } else { - ticktickUtilImpl.beginAuth() - } - return ticktickUtilImpl -} - -func (t *TicktickUtilImpl) HandleAuthCode(w http.ResponseWriter, r *http.Request) { - state := r.URL.Query().Get("state") - code := r.URL.Query().Get("code") - if state != t.authState { - slog.Warn(fmt.Sprintln("HandleAuthCode Invalid state", state)) - http.Error(w, "Invalid state", http.StatusBadRequest) - return - } - params := map[string]string{ - "code": code, - "grant_type": "authorization_code", - "scope": "tasks:read tasks:write", - "redirect_uri": viper.GetString("ticktick.redirectUri"), - } - formedParams := url.Values{} - for key, value := range params { - formedParams.Add(key, value) - } - - req, err := http.NewRequest("POST", "https://ticktick.com/oauth/token", bytes.NewBufferString(formedParams.Encode())) - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error creating request", err)) - http.Error(w, "Error creating request", http.StatusInternalServerError) - return - } - client := &http.Client{ - Timeout: time.Second * 10, - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.SetBasicAuth(viper.GetString("ticktick.clientId"), viper.GetString("ticktick.clientSecret")) - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error sending request", err)) - http.Error(w, "Error sending request", http.StatusInternalServerError) - return - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - slog.Warn(fmt.Sprintln("HandleAuthCode Unexpected response status", resp.StatusCode)) - http.Error(w, "Unexpected response status", http.StatusInternalServerError) - return - } - decoder := json.NewDecoder(resp.Body) - var tokenResponse map[string]interface{} - err = decoder.Decode(&tokenResponse) - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error decoding response", err)) - http.Error(w, "Error decoding response", http.StatusInternalServerError) - return - } - token := tokenResponse["access_token"].(string) - viper.Set("ticktick.token", token) - err = viper.WriteConfig() - if err != nil { - slog.Warn(fmt.Sprintln("HandleAuthCode Error writing config", err)) - http.Error(w, "Error writing config", http.StatusInternalServerError) - return - } - w.Write([]byte("Authorization successful")) -} - -func (t *TicktickUtilImpl) GetTasks(projectId string) []Task { - getTaskUrl := fmt.Sprintf("https://api.ticktick.com/open/v1/project/%s/data", projectId) - token := viper.GetString("ticktick.token") - req, err := http.NewRequest("GET", getTaskUrl, nil) - req.Header.Set("Authorization", "Bearer "+token) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error creating request to TickTick", err)) - return nil - } - var projectData ProjectData - client := &http.Client{ - Timeout: time.Second * 10, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error sending request to TickTick", err)) - return nil - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error response from TickTick", resp.Status)) - return nil - } - if resp.StatusCode == http.StatusNotFound { - return nil - } - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&projectData) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.GetTasks Error decoding response from TickTick", err)) - return nil - } - - return projectData.Tasks -} - -func (t *TicktickUtilImpl) HasDuplicateTask(projectId string, taskTitile string) bool { - tasks := t.GetTasks(projectId) - for _, task := range tasks { - if task.Title == taskTitile { - return true - } - } - return false -} - -func (t *TicktickUtilImpl) CreateTask(task Task) error { - if t.HasDuplicateTask(task.ProjectId, task.Title) { - return nil - } - token := viper.GetString("ticktick.token") - createTaskUrl := "https://api.ticktick.com/open/v1/task" - payload, err := json.Marshal(task) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error marshalling", err)) - return err - } - req, err := http.NewRequest("POST", createTaskUrl, bytes.NewBuffer(payload)) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error creating request to TickTick", err)) - return err - } - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Timeout: time.Second * 10, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error sending request to TickTick", err)) - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - slog.Warn(fmt.Sprintln("Ticktick.CreateTask Error response from TickTick", resp.Status)) - return fmt.Errorf("error response from TickTick: %s", resp.Status) - } - return nil -} - -func getProjects() ([]Project, error) { - token := viper.GetString("ticktick.token") - req, err := http.NewRequest("GET", "https://api.ticktick.com/open/v1/project/", nil) - req.Header.Set("Authorization", "Bearer "+token) - if err != nil { - slog.Warn(fmt.Sprintln("Error creating request to TickTick", err)) - return nil, err - } - client := &http.Client{ - Timeout: time.Second * 10, - } - resp, err := client.Do(req) - if err != nil { - slog.Warn(fmt.Sprintln("Error sending request to TickTick", err)) - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - slog.Warn(fmt.Sprintln("Error response from TickTick", resp.Status)) - return nil, fmt.Errorf("error response from TickTick: %s", resp.Status) - } - var projects []Project - decoder := json.NewDecoder(resp.Body) - err = decoder.Decode(&projects) - if err != nil { - slog.Warn(fmt.Sprintln("Error decoding response from TickTick", err)) - return nil, err - } - return projects, nil -} - -func (t *TicktickUtilImpl) beginAuth() { - if !viper.InConfig("ticktick.redirectUri") { - slog.Error("TickTick redirectUri not found in config file, exiting..") - os.Exit(1) - } - baseUrl := "https://ticktick.com/oauth/authorize?" - authUrl, _ := url.Parse(baseUrl) - authStateBytes := make([]byte, 6) - _, err := rand.Read(authStateBytes) - if err != nil { - slog.Error(fmt.Sprintln("Error generating auth state", err)) - os.Exit(1) - } - t.authState = hex.EncodeToString(authStateBytes) - params := url.Values{} - params.Add("client_id", viper.GetString("ticktick.clientId")) - params.Add("response_type", "code") - params.Add("redirect_uri", viper.GetString("ticktick.redirectUri")) - params.Add("state", t.authState) - params.Add("scope", "tasks:read tasks:write") - authUrl.RawQuery = params.Encode() - slog.Info(fmt.Sprintln("Please visit the following URL to authorize TickTick:", authUrl.String())) -} diff --git a/openapi/openapi.json b/openapi/openapi.json index 2e87da7..b03671e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Home Automation Backend (Python)", - "description": "Python rewrite skeleton for the home automation backend. This stage provides only the foundation for future module migration.", + "description": "Home automation backend with auth, runtime config, Home Assistant integrations, TickTick integration, and SQLite-backed recorders.", "version": "0.1.0" }, "paths": { @@ -324,6 +324,44 @@ } } } + }, + "/ticktick/auth/start": { + "get": { + "tags": [ + "ticktick" + ], + "summary": "Start Ticktick Auth", + "operationId": "start_ticktick_auth_ticktick_auth_start_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/ticktick/auth/code": { + "get": { + "tags": [ + "ticktick" + ], + "summary": "Handle Ticktick Auth Code", + "operationId": "handle_ticktick_auth_code_ticktick_auth_code_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } } }, "components": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 9c8f4cb..b0dde44 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,8 +1,8 @@ openapi: 3.1.0 info: title: Home Automation Backend (Python) - description: Python rewrite skeleton for the home automation backend. This stage - provides only the foundation for future module migration. + description: Home automation backend with auth, runtime config, Home Assistant integrations, + TickTick integration, and SQLite-backed recorders. version: 0.1.0 paths: /status: @@ -203,6 +203,30 @@ paths: content: application/json: schema: {} + /ticktick/auth/start: + get: + tags: + - ticktick + summary: Start Ticktick Auth + operationId: start_ticktick_auth_ticktick_auth_start_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + /ticktick/auth/code: + get: + tags: + - ticktick + summary: Handle Ticktick Auth Code + operationId: handle_ticktick_auth_code_ticktick_auth_code_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} components: schemas: Body_change_password_submit_config_change_password_post: diff --git a/pyproject.toml b/pyproject.toml index 3eb7047..9979338 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-automation-python" version = "0.1.0" -description = "Python rewrite skeleton for the home automation backend." +description = "Home automation backend with auth, integrations, and SQLite-backed services." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_app.py b/tests/test_app.py index 9e7ba37..cd9900d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -124,6 +124,48 @@ def test_app_start_seeds_missing_config_from_env_without_overwriting_existing_va reset_auth_db_caches() +def test_app_start_syncs_app_hostname_from_env_even_when_db_has_old_value( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + app_database_url = _prepare_app_db(tmp_path) + location_database_path = tmp_path / "location_ready.db" + poo_database_path = tmp_path / "poo_ready.db" + command.upgrade(_make_alembic_config(f"sqlite:///{location_database_path}"), "head") + command.upgrade(_make_poo_alembic_config(f"sqlite:///{poo_database_path}"), "head") + + app_database_path = tmp_path / "app_ready.db" + conn = sqlite3.connect(app_database_path) + conn.execute( + "INSERT INTO app_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + ("APP_HOSTNAME", "old.example.com"), + ) + conn.commit() + conn.close() + + monkeypatch.setenv("APP_DATABASE_URL", app_database_url) + monkeypatch.setenv("AUTH_BOOTSTRAP_USERNAME", "admin") + monkeypatch.setenv("AUTH_BOOTSTRAP_PASSWORD", "test-password") + monkeypatch.setenv("APP_HOSTNAME", "new.example.com") + monkeypatch.setenv("LOCATION_DATABASE_URL", f"sqlite:///{location_database_path}") + monkeypatch.setenv("POO_DATABASE_URL", f"sqlite:///{poo_database_path}") + get_settings.cache_clear() + reset_auth_db_caches() + + app = create_app() + anyio.run(_run_lifespan, app) + + conn = sqlite3.connect(app_database_path) + try: + rows = dict(conn.execute("SELECT key, value FROM app_config").fetchall()) + finally: + conn.close() + + assert rows["APP_HOSTNAME"] == "new.example.com" + + get_settings.cache_clear() + reset_auth_db_caches() + + def test_app_start_fails_when_location_db_missing( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: