23 Commits

Author SHA1 Message Date
tliu93 4acdd2dc60 fix ci test
pytest / test (push) Successful in 45s
pytest / test (pull_request) Successful in 44s
2026-04-22 13:31:26 +02:00
tliu93 a76d6bfb71 change adoption to separate step
pytest / test (push) Failing after 46s
pytest / test (pull_request) Failing after 45s
2026-04-22 13:28:00 +02:00
tliu93 35aee79d93 Restore legacy poo inbound dispatch
pytest / test (push) Successful in 43s
docker-image / build-and-push (push) Successful in 3m38s
2026-04-20 23:33:57 +02:00
tliu93 b9e7f51d51 Split compose dev build from registry deploy
pytest / test (push) Successful in 44s
2026-04-20 23:16:13 +02:00
tliu93 94747c75dd Align image publishing with repository path
pytest / test (push) Successful in 43s
docker-image / build-and-push (push) Successful in 3m37s
2026-04-20 23:05:27 +02:00
tliu93 7978a7e1e1 Add release Docker image workflow
pytest / test (push) Successful in 42s
docker-image / build-and-push (push) Successful in 3m42s
2026-04-20 22:18:54 +02:00
tliu93 e9e2034d30 Add Grafana to deployment compose
pytest / test (push) Successful in 40s
2026-04-20 20:50:46 +02:00
tliu93 aae8ca3b87 Merge pull request 'refactoring/new_python' (#3) from refactoring/new_python into main
pytest / test (push) Successful in 41s
Reviewed-on: #3
2026-04-20 20:41:01 +02:00
tliu93 1805d5d8ea Finalize first Python release
pytest / test (push) Successful in 40s
pytest / test (pull_request) Successful in 41s
2026-04-20 20:40:04 +02:00
tliu93 795c84f177 Stabilize auth tests in CI
pytest / test (push) Successful in 43s
2026-04-20 17:43:24 +02:00
tliu93 1ff426d2e9 Add pytest workflow
pytest / test (push) Failing after 2m18s
2026-04-20 17:38:32 +02:00
tliu93 fe0409dafe Refine runtime config and redirect settings 2026-04-20 17:36:05 +02:00
tliu93 982af62f4f Migrate TickTick OAuth and action tasks 2026-04-20 17:06:03 +02:00
tliu93 179aae264e Persist runtime config in app db and seed from env 2026-04-20 15:56:10 +02:00
tliu93 3f7c9e43d9 Switch auth password hashing to Argon2 2026-04-20 15:26:36 +02:00
tliu93 e1aad408ab Add auth foundation and app DB management 2026-04-20 15:16:47 +02:00
tliu93 044b47c573 Migrate poo recorder and align Alembic naming 2026-04-20 11:48:48 +02:00
tliu93 e334df992f Add Home Assistant inbound gateway 2026-04-20 10:42:35 +02:00
tliu93 151ad46275 Add Home Assistant outbound adapter 2026-04-20 10:11:02 +02:00
tliu93 eb487ccb46 Track exported OpenAPI schema 2026-04-19 23:25:13 +02:00
tliu93 d0dc8e893a Tighten location request validation 2026-04-19 23:18:20 +02:00
tliu93 1a2f9c75d9 Harden location db startup validation 2026-04-19 23:02:43 +02:00
tliu93 8aeb0723c1 Add location db adoption runbook 2026-04-19 21:57:31 +02:00
114 changed files with 7198 additions and 3737 deletions
+27 -9
View File
@@ -1,14 +1,32 @@
# Required: bootstrap and core app settings.
# These values should be set before the container starts.
APP_NAME=Home Automation Backend (Python) APP_NAME=Home Automation Backend (Python)
APP_ENV=development APP_ENV=production
APP_DEBUG=true APP_HOSTNAME=home-automation.example.com
APP_HOST=0.0.0.0 APP_DATABASE_URL=sqlite:////app/data/app.db
APP_PORT=8000 LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db
POO_DATABASE_URL=sqlite:///./data/pooRecorder.db AUTH_BOOTSTRAP_USERNAME=admin
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=
POO_SENSOR_FRIENDLY_NAME=
# Optional: TickTick integration.
# APP_HOSTNAME is used to derive the OAuth callback URI automatically.
TICKTICK_CLIENT_ID= TICKTICK_CLIENT_ID=
TICKTICK_CLIENT_SECRET= TICKTICK_CLIENT_SECRET=
TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback
TICKTICK_TOKEN= TICKTICK_TOKEN=
HOME_ASSISTANT_BASE_URL=http://localhost:8123
HOME_ASSISTANT_AUTH_TOKEN=
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
+43
View File
@@ -0,0 +1,43 @@
name: docker-image
on:
push:
tags:
- "v*"
env:
REGISTRY_HOST: code.wanderingbadger.dev
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push multi-arch image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.REGISTRY_HOST }}/${{ env.IMAGE_NAME }}:latest
+31
View File
@@ -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
-1
View File
@@ -5,4 +5,3 @@
__pycache__/ __pycache__/
*.pyc *.pyc
data/ data/
openapi/
+8 -2
View File
@@ -9,12 +9,18 @@ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app COPY app ./app
COPY alembic ./alembic COPY alembic_app ./alembic_app
COPY alembic.ini ./ COPY alembic_app.ini ./
COPY alembic_location ./alembic_location
COPY alembic_location.ini ./
COPY alembic_poo ./alembic_poo
COPY alembic_poo.ini ./
COPY scripts ./scripts COPY scripts ./scripts
COPY docker ./docker
COPY README.md ./ COPY README.md ./
RUN mkdir -p /app/data RUN mkdir -p /app/data
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+188 -51
View File
@@ -1,57 +1,62 @@
# Home Automation Backend # 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 基础设施
- 极简 server-side templates
- location recorder 第一版迁移
- pytest 测试基础
- OpenAPI 导出脚本
- Docker / Compose 基础骨架
当前阶段明确不包含:
- TickTick 业务逻辑迁移
- Home Assistant 业务逻辑迁移
- poo records 业务迁移
- Notion 模块 - Notion 模块
Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。
旧 Go 代码位置:
- `legacy/go-backend/src/`
- `legacy/go-backend/helper/`
- `legacy/go-backend/.github/workflows/`
## 当前配置现实 ## 当前配置现实
当前系统仍然是个独立的 SQLite 数据库文件,而不是单一数据库: 当前系统仍然是个独立的 SQLite 数据库文件,而不是单一数据库:
- `app` 级共享数据使用自己的 DB 文件
- `location` 模块使用自己的 DB 文件 - `location` 模块使用自己的 DB 文件
- `poo` 模块未来也将使用自己的 DB 文件 - `poo` 模块使用自己的 DB 文件
当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点: 当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点:
- `APP_DATABASE_URL`
- `LOCATION_DATABASE_URL` - `LOCATION_DATABASE_URL`
- `POO_DATABASE_URL` - `POO_DATABASE_URL`
目前真正接入的是 `location` 对应的数据库;`poo` 先保留配置占位,等模块迁入时再接上 目前 auth、`location` `poo` 都已经接到各自独立的数据库文件
其中 `app` 级共享 DB 当前主要用于:
- 单个 admin 用户
- server-side session
- runtime config 持久化
这部分现在也使用 Alembic 管理:
- `app db` 不会在应用启动时自动创建
- 需要先运行 `python scripts/app_db_adopt.py`
- 这个脚本会创建新 DB 并建好 schema
## 当前目录 ## 当前目录
Python 骨架的主要目录如下: 主要目录如下:
- `app/`: FastAPI 应用代码 - `app/`: FastAPI 应用代码
- `alembic/`: Alembic migration 环境 - `alembic_app/`: App DB 的 Alembic migration 环境
- `alembic_location/`: Location DB 的 Alembic migration 环境
- `alembic_poo/`: Poo DB 的 Alembic migration 环境
- `tests/`: pytest 测试 - `tests/`: pytest 测试
- `docs/`: 架构说明与迁移文档 - `docs/`: 当前系统说明文档
- `scripts/`: 辅助脚本,例如 OpenAPI 导出 - `scripts/`: 辅助脚本,例如 OpenAPI 导出
## 依赖管理 ## 依赖管理
@@ -99,7 +104,13 @@ pip install -r dev-requirements.txt
cp .env.example .env cp .env.example .env
``` ```
3. 启动服务 3. 初始化数据库
```bash
python -m scripts.run_migrations
```
4. 启动服务
```bash ```bash
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
@@ -114,20 +125,158 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 数据库与 Alembic ## 数据库与 Alembic
当前默认使用 SQLite但要明确区分个数据库文件: 当前默认使用 SQLite区分个数据库文件:
- App DB`sqlite:///./data/app.db`
- Location DB`sqlite:///./data/locationRecorder.db` - Location DB`sqlite:///./data/locationRecorder.db`
- Poo DB`sqlite:///./data/pooRecorder.db` - Poo DB`sqlite:///./data/pooRecorder.db`
- 数据目录:`./data/` - 数据目录:`./data/`
初始化 migration 环境后,可继续添加模型并生成迁移: 初始化 migration 环境后,可继续添加模型并生成迁移:
当前 `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/`
- 统一 migration job`python -m scripts.run_migrations`
- 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 鉴权层,用于保护配置页面与管理能力。
- 认证模型:`username/password`
- 会话模型:server-side session + cookie
- 当前主要受保护页面:`/config`
- 当前公开页面:`/login`
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
安全实现的当前边界:
- 密码使用 Argon2 做哈希存储
- 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 只用于首个用户落库,不是后续的完整配置管理方案。
当前前端主要有两条页面路径:
- `/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 表明文存储
## OpenAPI
可使用下面的脚本重新导出当前 API 定义:
```bash ```bash
alembic revision --autogenerate -m "init tables" python scripts/export_openapi.py
alembic upgrade head
``` ```
当前 Alembic 只接管 `location` 这条链路;`poo` 相关数据库与 migration 还没有迁入。 导出结果会写入:
- `openapi/openapi.json`
- `openapi/openapi.yaml`
## Docker Compose
当前默认 Compose 服务名为 `app`,容器名固定为 `home-automation-app`
当前 Compose 分成两层:
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取
- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
本地开发启动方式:
```bash
docker compose up -d --build
```
上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。
如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件:
```bash
docker compose -f docker-compose.yml pull
docker compose -f docker-compose.yml up -d
```
持续查看日志:
```bash
docker compose logs -f app
```
## Container Image CI
项目提供了一个 release image workflow
- workflow 文件:`.github/workflows/docker-image.yml`
- 触发条件:push 匹配 `v*` 的 tag,例如 `v1.0.0`
- registry`code.wanderingbadger.dev`
- image`code.wanderingbadger.dev/<owner>/<repo>`
`docker-compose.yml` 中生产默认使用的 app image 当前为:
- `code.wanderingbadger.dev/tliu93/home-automation:latest`
当前 workflow 不再把 image name 硬编码到特定 user package 路径,而是直接使用当前仓库标识生成镜像路径:
- `code.wanderingbadger.dev/${github.repository}:${tag}`
在 Gitea 这里,package 更贴近 repo 归属的语义,主要体现在镜像命名路径本身,而不是额外的“绑定”动作。也就是说,当前发布方式是按仓库路径约定来对齐 repo/package 语义。
这个 workflow 会构建并推送 multi-arch image
- `linux/amd64`
- `linux/arm64`
推送的 tag
- release tag 本身,例如 `v1.0.0`
- `latest`
workflow 依赖以下 secrets
- `REGISTRY_USERNAME`
- `REGISTRY_TOKEN`
CI 产出的 image 是给部署机直接 `docker pull` 使用的。部署机不需要 checkout 本仓库,也不需要本地执行 `docker build`
## 运行测试 ## 运行测试
@@ -139,6 +288,7 @@ pytest
- app 基本启动测试 - app 基本启动测试
- `/status` endpoint 测试 - `/status` endpoint 测试
- 登录 / session 基础流程测试
## OpenAPI 导出 ## OpenAPI 导出
@@ -153,6 +303,8 @@ python scripts/export_openapi.py
- `openapi/openapi.json` - `openapi/openapi.json`
- `openapi/openapi.yaml` - `openapi/openapi.yaml`
`openapi/` 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。
## 容器启动 ## 容器启动
1. 准备环境变量文件 1. 准备环境变量文件
@@ -175,18 +327,3 @@ SQLite 持久化目录:
- 本地 `./data` - 本地 `./data`
- 容器内 `/app/data` - 容器内 `/app/data`
## 后续迁移建议
后续可以在当前骨架上继续迁移这些模块:
- TickTick integration
- Home Assistant integration
- poo records
建议继续参考:
- [当前系统盘点](docs/current-system-inventory.md)
- [Python 重构方案](docs/python-rewrite-plan.md)
- [迁移风险清单](docs/migration-risks.md)
- [Location Recorder 接管说明](docs/location-recorder.md)
+37
View File
@@ -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
+49
View File
@@ -0,0 +1,49 @@
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.config import AppConfigEntry # noqa: F401
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()
+25
View File
@@ -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"}
@@ -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")
@@ -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")
+1 -1
View File
@@ -1,5 +1,5 @@
[alembic] [alembic]
script_location = alembic script_location = alembic_location
prepend_sys_path = . prepend_sys_path = .
path_separator = os path_separator = os
sqlalchemy.url = sqlite:///./data/locationRecorder.db sqlalchemy.url = sqlite:///./data/locationRecorder.db
+37
View File
@@ -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
+48
View File
@@ -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()
@@ -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")
+1 -1
View File
@@ -1,2 +1,2 @@
"""Application package for the Python rewrite skeleton.""" """Application package for the home automation backend."""
+234
View File
@@ -0,0 +1,234 @@
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,
)
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"))
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="/config", 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="/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,
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("/config/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_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",
)
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_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="/config", 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_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,
"config.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,
"config_error": None,
"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,
},
status_code=status_code,
)
+86
View File
@@ -0,0 +1,86 @@
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_db,
get_homeassistant_client,
get_poo_db,
get_ticktick_client,
)
from app.integrations.homeassistant import (
HomeAssistantClient,
HomeAssistantConfigError,
HomeAssistantRequestError,
)
from app.integrations.ticktick import TickTickClient, TickTickConfigError, TickTickRequestError
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),
poo_db: Session = Depends(get_poo_db),
settings: Settings = Depends(get_app_settings),
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
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,
ticktick_client=ticktick_client,
poo_session=poo_db,
settings=settings,
homeassistant_client=homeassistant_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)
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 (
TickTickConfigError,
TickTickRequestError,
HomeAssistantConfigError,
HomeAssistantRequestError,
RuntimeError,
) as exc:
logger.warning("Home Assistant publish request failed during integration 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)
return Response(status_code=status.HTTP_200_OK)
+13 -6
View File
@@ -1,6 +1,7 @@
import json import json
import logging
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import PlainTextResponse, Response from fastapi.responses import PlainTextResponse, Response
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -10,6 +11,8 @@ from app.schemas.location import LocationRecordRequest
from app.services.location import record_location from app.services.location import record_location
router = APIRouter(tags=["location"]) router = APIRouter(tags=["location"])
logger = logging.getLogger(__name__)
BAD_REQUEST_MESSAGE = "bad request"
@router.post("/location/record") @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() raw_payload = await request.body()
data = json.loads(raw_payload) data = json.loads(raw_payload)
payload = LocationRecordRequest.model_validate(data) payload = LocationRecordRequest.model_validate(data)
record_location(db, payload)
except json.JSONDecodeError as exc: 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=status.HTTP_400_BAD_REQUEST)
except ValidationError as exc: 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=status.HTTP_400_BAD_REQUEST)
record_location(db, payload) except ValueError as exc:
return Response(status_code=200) logger.warning("Rejected location request due to invalid numeric input: %s", exc)
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
return Response(status_code=status.HTTP_200_OK)
+137 -7
View File
@@ -1,21 +1,151 @@
import logging
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.config import Settings from app.config import Settings, get_settings
from app.dependencies import get_app_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,
is_ticktick_oauth_ready,
save_config_updates,
)
from sqlalchemy.orm import Session
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
router = APIRouter(tags=["pages"]) 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) @router.get("/", response_class=HTMLResponse)
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse: 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_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:
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 = { context = {
"app_name": settings.app_name, "app_name": settings.app_name,
"app_env": settings.app_env, "app_env": settings.app_env,
"notion_status": "Legacy scope, removed from the Python rewrite target.", "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": 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_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": ticktick_oauth_notice,
"ticktick_oauth_error": ticktick_oauth_error,
} }
return templates.TemplateResponse(request, "home.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),
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
"ticktick_oauth_notice": None,
"ticktick_oauth_error": None,
}
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 = 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),
"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,
}
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)
+76
View File
@@ -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)
+79
View File
@@ -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,
)
+53
View File
@@ -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()
+41 -4
View File
@@ -7,27 +7,36 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "Home Automation Backend (Python)" app_name: str = "Home Automation Backend (Python)"
app_env: str = "development" app_env: str = "production"
app_debug: bool = False app_debug: bool = False
app_host: str = "0.0.0.0" app_hostname: str = "localhost:8000"
app_port: int = 8000 app_database_url: str = "sqlite:///./data/app.db"
location_database_url: str = "sqlite:///./data/locationRecorder.db" location_database_url: str = "sqlite:///./data/locationRecorder.db"
poo_database_url: str = "sqlite:///./data/pooRecorder.db" poo_database_url: str = "sqlite:///./data/pooRecorder.db"
ticktick_client_id: str = "" ticktick_client_id: str = ""
ticktick_client_secret: str = "" ticktick_client_secret: str = ""
ticktick_redirect_uri: str = ""
ticktick_token: str = "" ticktick_token: str = ""
home_assistant_base_url: str = "" home_assistant_base_url: str = ""
home_assistant_auth_token: str = "" home_assistant_auth_token: str = ""
home_assistant_timeout_seconds: float = 1.0
home_assistant_action_task_project_id: str = "" 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"
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 = True
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
case_sensitive=False, case_sensitive=False,
extra="ignore",
) )
@computed_field @computed_field
@@ -35,6 +44,22 @@ class Settings(BaseSettings):
def is_development(self) -> bool: def is_development(self) -> bool:
return self.app_env.lower() == "development" 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 @staticmethod
def _sqlite_path_from_url(database_url: str) -> Path | None: def _sqlite_path_from_url(database_url: str) -> Path | None:
prefix = "sqlite:///" prefix = "sqlite:///"
@@ -48,11 +73,23 @@ class Settings(BaseSettings):
def location_sqlite_path(self) -> Path | None: def location_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.location_database_url) 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 @computed_field
@property @property
def poo_sqlite_path(self) -> Path | None: def poo_sqlite_path(self) -> Path | None:
return self._sqlite_path_from_url(self.poo_database_url) 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 @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
+33 -2
View File
@@ -1,15 +1,46 @@
from collections.abc import Generator from collections.abc import Generator
from fastapi import Depends, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth_db import get_auth_db_session
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.db import get_db_session 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
def get_app_settings() -> Settings: def get_auth_db() -> Generator[Session, None, None]:
return get_settings() 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]: def get_db() -> Generator[Session, None, None]:
yield from get_db_session() yield from get_db_session()
def get_poo_db() -> Generator[Session, None, None]:
yield from get_poo_db_session()
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
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),
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)
+97 -1
View File
@@ -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 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) @dataclass(slots=True)
class HomeAssistantClient: class HomeAssistantClient:
settings: Settings 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: def is_configured(self) -> bool:
return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) 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}"
+291 -2
View File
@@ -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 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) @dataclass(slots=True)
class TickTickClient: class TickTickClient:
settings: Settings settings: Settings
auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store)
timeout_seconds: float = 10.0
def is_configured(self) -> bool: 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
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 APP_HOSTNAME for OAuth callback generation."
)
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."
)
+59 -3
View File
@@ -3,16 +3,65 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
from app import models # noqa: F401 from app import models # noqa: F401
from app.api.routes.auth import router as auth_router
from app.api.routes import pages, status 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.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.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema
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
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())
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:
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:
return
try:
validate_location_runtime_db(settings.location_database_url)
except LocationDatabaseAdoptionError as exc:
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: def ensure_runtime_dirs() -> None:
settings = get_settings() 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: if path is not None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
@@ -20,6 +69,9 @@ def ensure_runtime_dirs() -> None:
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
ensure_runtime_dirs() ensure_runtime_dirs()
ensure_auth_db_ready()
ensure_location_db_ready()
ensure_poo_db_ready()
yield yield
@@ -31,8 +83,8 @@ def create_app() -> FastAPI:
version="0.1.0", version="0.1.0",
lifespan=lifespan, lifespan=lifespan,
description=( description=(
"Python rewrite skeleton for the home automation backend. " "Home automation backend with auth, runtime config, Home Assistant "
"This stage provides only the foundation for future module migration." "integrations, TickTick integration, and SQLite-backed recorders."
), ),
) )
@@ -40,8 +92,12 @@ def create_app() -> FastAPI:
app.mount("/static", StaticFiles(directory=static_dir), name="static") app.mount("/static", StaticFiles(directory=static_dir), name="static")
app.include_router(status.router) app.include_router(status.router)
app.include_router(auth_router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(homeassistant_router)
app.include_router(location_router) app.include_router(location_router)
app.include_router(poo_router)
app.include_router(ticktick_router)
return app return app
+3 -1
View File
@@ -1,5 +1,7 @@
"""SQLAlchemy models package.""" """SQLAlchemy models package."""
from app.models.auth import AuthSession, AuthUser
from app.models.config import AppConfigEntry
from app.models.location import Location from app.models.location import Location
__all__ = ["Location"] __all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
+33
View File
@@ -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")
+15
View File
@@ -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)
+13
View File
@@ -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)
+28
View File
@@ -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()
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class HomeAssistantPublishEnvelope(BaseModel):
target: str
action: str
content: str
model_config = ConfigDict(extra="forbid")
+1 -2
View File
@@ -5,7 +5,6 @@ class LocationRecordRequest(BaseModel):
person: str person: str
latitude: str latitude: str
longitude: str longitude: str
altitude: str = "" altitude: str | None = None
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict
class PooRecordRequest(BaseModel):
status: str
latitude: str
longitude: str
model_config = ConfigDict(extra="forbid")
+9
View File
@@ -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)
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
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
from app.config import Settings
from app.models.auth import AuthSession, AuthUser
logger = logging.getLogger(__name__)
password_hasher = PasswordHasher()
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:
return password_hasher.hash(password)
def verify_password(password: str, stored_hash: str) -> bool:
try:
return password_hasher.verify(stored_hash, password)
except VerifyMismatchError:
return False
except (InvalidHashError, VerificationError):
return False
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 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
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)
+271
View File
@@ -0,0 +1,271 @@
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_HOSTNAME", "app_hostname", "App Hostname"),
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_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 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:
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 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.app_hostname
and settings.ticktick_client_id
and settings.ticktick_client_secret
)
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_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_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,
}
+116
View File
@@ -0,0 +1,116 @@
from __future__ import annotations
import json
from datetime import UTC, datetime, time, timedelta
from sqlalchemy.orm import Session
from app.config import Settings
from app.integrations.homeassistant import HomeAssistantClient
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
from app.services.poo import publish_latest_poo_status
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,
ticktick_client: TickTickClient | None = None,
poo_session: Session | None = None,
settings: Settings | None = None,
homeassistant_client: HomeAssistantClient | None = None,
) -> None:
if envelope.target == "location_recorder":
_handle_location_message(session, envelope)
return
if envelope.target == "poo_recorder":
_handle_poo_message(
envelope,
poo_session=poo_session,
settings=settings,
homeassistant_client=homeassistant_client,
)
return
if envelope.target == "ticktick":
_handle_ticktick_message(envelope, ticktick_client)
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)
def _handle_poo_message(
envelope: HomeAssistantPublishEnvelope,
*,
poo_session: Session | None,
settings: Settings | None,
homeassistant_client: HomeAssistantClient | None,
) -> None:
if envelope.action != "get_latest":
raise UnsupportedHomeAssistantMessage(
f"Unsupported Home Assistant target/action: {envelope.target}/{envelope.action}"
)
if poo_session is None or settings is None or homeassistant_client is None:
raise RuntimeError("Poo recorder integration is unavailable")
publish_latest_poo_status(
session=poo_session,
settings=settings,
homeassistant_client=homeassistant_client,
)
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)
+11 -5
View File
@@ -7,13 +7,20 @@ from app.models.location import Location
from app.schemas.location import LocationRecordRequest from app.schemas.location import LocationRecordRequest
def _parse_float_compat(value: str) -> float: def _parse_optional_float_compat(value: str | None) -> float:
try: try:
return float(value) return float(value)
except (TypeError, ValueError): except (TypeError, ValueError):
return 0.0 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: def _utc_now_rfc3339() -> str:
now = datetime.now(timezone.utc).replace(microsecond=0) now = datetime.now(timezone.utc).replace(microsecond=0)
return now.isoformat().replace("+00:00", "Z") return now.isoformat().replace("+00:00", "Z")
@@ -26,11 +33,10 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
.values( .values(
person=payload.person, person=payload.person,
datetime=_utc_now_rfc3339(), datetime=_utc_now_rfc3339(),
latitude=_parse_float_compat(payload.latitude), latitude=_parse_required_float(payload.latitude, "latitude"),
longitude=_parse_float_compat(payload.longitude), longitude=_parse_required_float(payload.longitude, "longitude"),
altitude=_parse_float_compat(payload.altitude), altitude=_parse_optional_float_compat(payload.altitude),
) )
) )
session.execute(stmt) session.execute(stmt)
session.commit() session.commit()
+112
View File
@@ -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
+151 -1
View File
@@ -61,6 +61,11 @@ h1 {
margin: 0; margin: 0;
} }
.single-column {
grid-template-columns: minmax(180px, 320px);
margin-bottom: 24px;
}
.meta div { .meta div {
padding: 16px; padding: 16px;
border-radius: 16px; border-radius: 16px;
@@ -83,6 +88,147 @@ a {
color: var(--accent); 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;
}
.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);
}
.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) { @media (max-width: 640px) {
.shell { .shell {
margin: 24px auto; margin: 24px auto;
@@ -91,5 +237,9 @@ a {
.panel { .panel {
padding: 24px; padding: 24px;
} }
}
.integration-action-row {
align-items: stretch;
flex-direction: column;
}
}
+2 -1
View File
@@ -4,7 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title> <title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="/static/styles.css">
</head> </head>
<body> <body>
<main class="shell"> <main class="shell">
+117
View File
@@ -0,0 +1,117 @@
{% extends "base.html" %}
{% block title %}Config · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">Configuration</p>
<h1>Config</h1>
{% if force_password_change %}
<div class="alert">
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
</div>
{% endif %}
{% if password_change_error %}
<div class="alert">{{ password_change_error }}</div>
{% endif %}
{% if config_error %}
<div class="alert">{{ config_error }}</div>
{% endif %}
{% if config_saved %}
<div class="notice">config saved to the app database. Some changes may require an app restart.</div>
{% endif %}
{% if ticktick_oauth_error %}
<div class="alert">{{ ticktick_oauth_error }}</div>
{% endif %}
{% if ticktick_oauth_notice %}
<div class="notice">{{ ticktick_oauth_notice }}</div>
{% endif %}
<div class="meta single-column">
<div>
<dt>当前用户</dt>
<dd>admin</dd>
</div>
</div>
<section class="config-block">
<h2>Change Password</h2>
<form class="auth-form" method="post" action="/config/change-password">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Current Password</span>
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
<span>New Password</span>
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
<span>Confirm New Password</span>
<input type="password" name="confirm_password" autocomplete="new-password" required>
</label>
<button type="submit">修改密码</button>
</form>
</section>
<section class="config-block">
<h2>Config</h2>
<form class="config-form" method="post" action="/config">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for section in config_sections %}
<fieldset class="config-section">
<legend>{{ section.name }}</legend>
{% for field in section.fields %}
<label>
<span>{{ field.label }}</span>
{% if field.secret %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
{% else %}
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
{% endif %}
</label>
{% endfor %}
{% if section.name == "TickTick" %}
<div class="integration-action-row">
<div>
<p class="integration-action-title">TickTick OAuth</p>
<p class="integration-action-copy">Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}</p>
{% if ticktick_oauth_ready %}
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
{% else %}
<p class="integration-action-copy">Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.</p>
{% endif %}
</div>
{% if ticktick_oauth_ready %}
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
{% else %}
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
{% endif %}
</div>
{% endif %}
</fieldset>
{% endfor %}
<button type="submit">Save Config</button>
</form>
</section>
<form class="logout-form" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">登出</button>
</form>
</section>
{% endblock %}
+4 -1
View File
@@ -23,6 +23,10 @@
<dt>OpenAPI</dt> <dt>OpenAPI</dt>
<dd><a href="/docs">/docs</a></dd> <dd><a href="/docs">/docs</a></dd>
</div> </div>
<div>
<dt>登录</dt>
<dd><a href="/login">/login</a></dd>
</div>
<div> <div>
<dt>Notion</dt> <dt>Notion</dt>
<dd>{{ notion_status }}</dd> <dd>{{ notion_status }}</dd>
@@ -30,4 +34,3 @@
</dl> </dl>
</section> </section>
{% endblock %} {% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}登录 · {{ app_name }}{% endblock %}
{% block content %}
<section class="panel auth-panel">
<p class="eyebrow">Authentication</p>
<h1>登录</h1>
<p class="lead">
登录成功后会进入受保护的 config 页面。
</p>
{% if error_message %}
<div class="alert">{{ error_message }}</div>
{% endif %}
<form class="auth-form" method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">登录</button>
</form>
</section>
{% endblock %}
+8
View File
@@ -8,6 +8,10 @@ alembic==1.18.4
# via -r requirements.in # via -r requirements.in
annotated-types==0.7.0 annotated-types==0.7.0
# via pydantic # via pydantic
argon2-cffi==25.1.0
# via -r requirements.in
argon2-cffi-bindings==25.1.0
# via argon2-cffi
anyio==4.13.0 anyio==4.13.0
# via # via
# httpx # httpx
@@ -19,6 +23,8 @@ certifi==2026.2.25
# via # via
# httpcore # httpcore
# httpx # httpx
cffi==2.0.0
# via argon2-cffi-bindings
click==8.3.2 click==8.3.2
# via # via
# pip-tools # pip-tools
@@ -82,6 +88,8 @@ python-dotenv==1.2.2
# uvicorn # uvicorn
python-multipart==0.0.26 python-multipart==0.0.26
# via -r requirements.in # via -r requirements.in
pycparser==2.23
# via cffi
pyyaml==6.0.3 pyyaml==6.0.3
# via # via
# -r requirements.in # -r requirements.in
+6
View File
@@ -0,0 +1,6 @@
services:
migration:
build: .
app:
build: .
+40 -13
View File
@@ -1,16 +1,43 @@
version: "3.9"
services: services:
app: migration:
build: . container_name: home-automation-migration
ports: image: code.wanderingbadger.dev/tliu93/home-automation:latest
- "8000:8000" user: "1000:1000"
env_file: restart: "no"
- .env init: true
environment: command: ["python", "-m", "scripts.run_migrations"]
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: volumes:
- ./data:/app/data - ./data:/app/data
- ./.env:/app/.env:ro
app:
container_name: home-automation-app
image: code.wanderingbadger.dev/tliu93/home-automation:latest
user: "1000:1000"
restart: unless-stopped
init: true
depends_on:
migration:
condition: service_completed_successfully
ports:
- "127.0.0.1:8881:8000"
volumes:
- ./data:/app/data
- ./.env:/app/.env:ro
grafana:
image: grafana/grafana:latest
container_name: home-automation-grafana
depends_on:
- app
restart: unless-stopped
ports:
- "10.238.75.70:8882:3000"
environment:
GF_PLUGINS_PREINSTALL: frser-sqlite-datasource
volumes:
- ./data:/data/home-automation:ro
- homeautomation_grafana_storage:/var/lib/grafana
volumes:
homeautomation_grafana_storage:
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
exec "$@"
+19 -4
View File
@@ -23,28 +23,44 @@
- 基础路由注册 - 基础路由注册
- `config.py` - `config.py`
- 环境变量驱动的 settings - 环境变量驱动的 settings
- `auth_db.py`
- app 级共享 auth 数据库
- `db.py` - `db.py`
- SQLAlchemy engine / session / Base - SQLAlchemy engine / session / Base
- `dependencies.py` - `dependencies.py`
- 通用依赖注入 - 通用依赖注入
- `api/` - `api/`
- HTTP routes - HTTP routes
- 当前已迁入 `/login``/logout``/admin`
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
- 当前已迁入 `POST /poo/record``GET /poo/latest`
- `models/` - `models/`
- SQLAlchemy models - SQLAlchemy models
- 当前 `auth``location``poo` 使用各自独立的数据库 base
- `schemas/` - `schemas/`
- Pydantic schemas - Pydantic schemas
- `services/` - `services/`
- 业务服务层 - 业务服务层
- 当前已迁入 config page 的 DB 持久化逻辑
- `integrations/` - `integrations/`
- 外部系统适配层占位 - 外部系统适配层
- 当前已迁入 Home Assistant outbound adapter
- `templates/` - `templates/`
- Jinja2 模板 - Jinja2 模板
- `static/` - `static/`
- 极简静态资源 - 极简静态资源
### `alembic/` ### `alembic_location/`
数据库 migration 基础设施。当前尚未迁入业务表,但迁移链路已就绪。 Location DB 的 migration 基础设施。
### `alembic_app/`
App DB 的 migration 基础设施。
### `alembic_poo/`
Poo DB 的 migration 基础设施。
### `tests/` ### `tests/`
@@ -76,4 +92,3 @@ Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确
- 不预留 Notion 相关业务流 - 不预留 Notion 相关业务流
如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。
+120
View File
@@ -0,0 +1,120 @@
# 基础鉴权说明
本文档说明当前 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`
- `app_config`
当前没有把 auth 数据和 `location` / `poo` DB 混放。
当前这部分现在也走 Alembic 管理:
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
- 初始化脚本:`python scripts/app_db_adopt.py`
当前没有 legacy app DB,所以这一版脚本只负责初始化新库,不负责 legacy adoption。
`app_config` 现在承接运行时配置持久化。
其中:
- `.env` 负责 bootstrap / fallback
- `app_config` 表负责运行时配置覆盖
- 登录密码仍然属于认证数据,使用 Argon2 哈希,不存进 `app_config`
## 首次启动与 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. 立即修改密码
## 安全设计
当前这版已经落实的基础安全点:
- 密码不明文存储,使用 Argon2 哈希
- session cookie 为 `HttpOnly`
- cookie 使用 `SameSite=Lax`
- `Secure` cookie 在非 `development` 环境默认开启
- 登录表单与登出表单都有基础 CSRF 校验
- session token 为随机生成,服务端只持久化 token hash
- session 有过期时间与显式失效机制
## 当前受保护范围
当前这轮只保护了页面入口:
- `GET /config`
- `POST /config`
- `POST /config/change-password`
- `POST /logout`
相关流程:
- `GET /login`
- `POST /login`
未登录访问 `/config` 时会被重定向到 `/login`
## 下一步不在本轮内
后续可以在这个基座上继续做:
- 配置页面接入
- config persistence
- 更细的受保护路由范围
- 用户初始化 / 密码轮换的更正式 runbook
-557
View File
@@ -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 500TickTick 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 配置文件
+67
View File
@@ -0,0 +1,67 @@
# 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`
- `ticktick / create_action_task`
其中:
- `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`
- 其他未定义 target/action
这些请求当前会返回:
- `500 internal server error`
## 错误处理
当前策略保持简洁:
- envelope 非法、缺字段、unknown field、`content` 非法:返回 `400 bad request`
- target/action 当前未迁入:返回 `500 internal server error`
对 caller 的响应体保持简洁,不暴露过多内部细节;更详细原因只写日志。
+51
View File
@@ -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 的出站契约和可复用结构迁进来。
+88 -8
View File
@@ -1,6 +1,13 @@
# Location Recorder # Location Recorder
本文档说明 `location recorder` 在 Python 项目中的当前数据库接管策略。 本文档说明 `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 事实基线
@@ -31,16 +38,51 @@ PRAGMA user_version = 2;
1. 把上述 `location` schema 视为 Alembic baseline 1. 把上述 `location` schema 视为 Alembic baseline
2. 新数据库通过 Alembic `upgrade head` 初始化 2. 新数据库通过 Alembic `upgrade head` 初始化
3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,通过 `alembic stamp` 接管 3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,通过 `alembic stamp` 接管
4. 未来不再以 `PRAGMA user_version` 作为主 migration 机制 4. 如果数据库已经存在 `alembic_version`,则必须先确认当前 revision 与项目预期 baseline 一致
5. 只有 revision 一致时,才视为该库已经被正确接管
6. 未来不再以 `PRAGMA user_version` 作为主 migration 机制
当前 baseline revision 是: 当前 baseline revision 是:
- `20260419_01_location_baseline` - `20260419_01_location_baseline`
当前提供的最小脚本入口是:
```bash
python scripts/location_db_adopt.py
```
如果你更喜欢模块方式运行,也可以用:
```bash
python -m scripts.location_db_adopt
```
它只针对 `LOCATION_DATABASE_URL` 工作,并且遵守保守接管原则:
- 本地已有 DB 文件:先校验,再接管
- 本地没有 DB 文件:按新库初始化
- 任一校验不通过:立即报错并停止
应用本身在启动时不会自动替你初始化 `location` 数据库。
应用启动时会对 `LOCATION_DATABASE_URL` 做只读校验:
- 文件不存在:直接报错,并提示先运行接管脚本
- 文件存在但还没有 `alembic_version`:直接报错,要求先完成 legacy 接管
- 文件已被 Alembic 管理但 revision 不匹配:直接报错并拒绝启动
这是有意为之,用来避免应用在错误路径上静默创建新库,或带着错误数据库版本继续跑业务。
## 新数据库初始化 ## 新数据库初始化
对于一个全新 SQLite 数据库,执行 如果本地不存在 `LOCATION_DATABASE_URL` 指向的 DB 文件
- 脚本会先创建父目录
- 然后执行 Alembic `upgrade head`
- 最终建立 `location` 表与 `alembic_version`
手工执行时也等价于:
```bash ```bash
alembic upgrade head alembic upgrade head
@@ -52,9 +94,15 @@ alembic upgrade head
对于已经存在的 legacy SQLite 数据库: 对于已经存在的 legacy SQLite 数据库:
1. 先确认`location` 表 schema 与 baseline 一致 1. 先确认 DB 文件存在
2. 旧库里的 `PRAGMA user_version = 2` 仅视为历史事实,不再继续沿用 2. 如果已经存在 `alembic_version` 表,则先读取当前 revision
3. 确认无误后,对该数据库执行 `stamp`,而不是重新跑创建表 migration 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 管理
示例: 示例:
@@ -62,12 +110,38 @@ alembic upgrade head
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 20260419_01_location_baseline 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 结构 - 告诉 Alembic:这个数据库已经处于 baseline 结构
- 不修改已有 `location` 表数据 - 不修改已有 `location` 表数据
- 后续 migration 由 Alembic 接管 - 后续 migration 由 Alembic 接管
## Fail Closed 原则
当前策略是保守接管,不做未知 legacy 状态的自动修复。
如果出现以下任一情况,脚本会直接报错并停止:
- 找不到 `location`
- `location` 表 schema 与 baseline 不一致
- `PRAGMA user_version` 不等于 `2`
- 已有 `alembic_version`,但 revision 与预期 baseline 不一致
- 目标 DB 不是 SQLite URL
当前不会尝试:
- 自动修表
- 自动调整 `user_version`
- 自动推断未知 legacy 状态
如果发生这些情况,应先人工确认数据库状态,再决定是否需要单独迁移或修复。
## 关于 `data/locationRecorder.db` ## 关于 `data/locationRecorder.db`
你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于: 你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于:
@@ -91,6 +165,12 @@ LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 2026041
- 构造一个“legacy 风格”的临时 SQLite 文件 - 构造一个“legacy 风格”的临时 SQLite 文件
- 建出同样的 `location` - 建出同样的 `location`
- 设置 `PRAGMA user_version = 2` - 设置 `PRAGMA user_version = 2`
- 再执行 Alembic `stamp` - 再执行接管脚本中的 adopt 逻辑
同时也覆盖:
- DB 文件不存在时的新库初始化路径
- schema 不匹配时的失败路径
- `user_version` 不匹配时的失败路径
这样可以验证接管路径,同时不污染真实样本库。 这样可以验证接管路径,同时不污染真实样本库。
-79
View File
@@ -1,79 +0,0 @@
# Migration Notes
本文档记录 Python skeleton 阶段的迁移说明,帮助后续继续推进时快速恢复上下文。
## 当前阶段完成内容
- 建立 FastAPI 应用骨架
- 建立环境变量配置体系
- 接入 SQLAlchemy 与 Alembic
- 建立 Jinja2 模板基础
- 建立 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
- 未实现真实 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)
## 后续建议顺序
建议继续沿用既有迁移文档中的顺序:
1. 先迁 `location recorder`
2. 再迁 Home Assistant 出站适配层
3. 再迁 TickTick adapter
4. 再迁 Home Assistant 命令网关
5. 最后迁 `poo recorder`
## 开发约束提醒
- 保持对当前 Go 外部行为的兼容意识
- 不要把旧 Python 版本当作设计基线
- 不要重新引入 Notion 作为 Python 主系统能力
- 在迁业务模块时,优先补 contract tests
-238
View File
@@ -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 实现,整个重构路线就会可控很多。
+140
View File
@@ -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 就继续保留兼容层
-314
View File
@@ -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 1Python 骨架与通用基础设施
- 建立 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,但会影响第一阶段“兼容到什么程度”的具体定义。
+43
View File
@@ -0,0 +1,43 @@
# 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`
## 当前配置项
- `APP_HOSTNAME`
- `TICKTICK_CLIENT_ID`
- `TICKTICK_CLIENT_SECRET`
- `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 库,先用标准库完成兼容行为
## 后续适合单独拆分的工作
- 给 config 页面增加明确的 TickTick 授权入口
- 增加 project 探测或选择能力,减少手工填写 `HOME_ASSISTANT_ACTION_TASK_PROJECT_ID`
- 如果后续发现 OAuth/token 生命周期需要更强健,再补 refresh token 或持久化 auth state
-18
View File
@@ -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/` 等目录演进
- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础
- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/`
-22
View File
@@ -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 ./...
-21
View File
@@ -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
@@ -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
-100
View File
@@ -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
View File
-41
View File
@@ -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.
}
-161
View File
@@ -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")
}
@@ -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
}
@@ -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")
}
@@ -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
}
@@ -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
}
-54
View File
@@ -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
)
-140
View File
@@ -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=
@@ -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")
}
@@ -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")
}
@@ -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()
}
@@ -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), &notionapi.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), &notionapi.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), &notionapi.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(&notionToken, "token", "", "Notion API token")
reverseCmd.Flags().StringVar(&notionTableId, "table-id", "", "Notion table id to reverse")
}
@@ -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.
}
@@ -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()
}
-11
View File
@@ -1,11 +0,0 @@
/*
Copyright © 2024 Tianyu Liu
*/
package main
import "github.com/t-liu93/home-automation-backend/cmd"
func main() {
cmd.Execute()
}
@@ -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")
}
}
-129
View File
@@ -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), &notionapi.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), &notionapi.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: &notionapi.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), &notionapi.AppendBlockChildrenRequest{
After: notionapi.BlockID(after),
Children: []notionapi.Block{tableRow},
})
return res.Results[0].GetID().String(), err
}
@@ -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()))
}
+494
View File
@@ -0,0 +1,494 @@
{
"openapi": "3.1.0",
"info": {
"title": "Home Automation Backend (Python)",
"description": "Home automation backend with auth, runtime config, Home Assistant integrations, TickTick integration, and SQLite-backed recorders.",
"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"
}
}
}
}
}
}
},
"/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"
}
}
}
}
}
}
},
"/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": [
"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": [
"pages"
],
"summary": "Home",
"operationId": "home__get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/admin": {
"get": {
"tags": [
"pages"
],
"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",
"content": {
"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": [
"location"
],
"summary": "Create Location Record",
"operationId": "create_location_record_location_record_post",
"responses": {
"200": {
"description": "Successful Response",
"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": {}
}
}
}
}
}
},
"/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": {
"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": {
"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": {
"type": "string",
"title": "Status"
}
},
"type": "object",
"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"
}
}
}
}
+317
View File
@@ -0,0 +1,317 @@
openapi: 3.1.0
info:
title: Home Automation Backend (Python)
description: Home automation backend with auth, runtime config, Home Assistant integrations,
TickTick integration, and SQLite-backed recorders.
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'
/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'
/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:
- 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:
- pages
summary: Home
operationId: home__get
responses:
'200':
description: Successful Response
content:
text/html:
schema:
type: string
/admin:
get:
tags:
- pages
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
content:
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:
- location
summary: Create Location Record
operationId: create_location_record_location_record_post
responses:
'200':
description: Successful Response
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: {}
/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:
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:
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:
type: string
title: Status
type: object
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
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-automation-python" name = "home-automation-python"
version = "0.1.0" 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" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1
View File
@@ -1,4 +1,5 @@
alembic>=1.14,<2.0 alembic>=1.14,<2.0
argon2-cffi>=25.1,<26.0
fastapi>=0.115,<0.116 fastapi>=0.115,<0.116
jinja2>=3.1,<4.0 jinja2>=3.1,<4.0
pydantic-settings>=2.6,<3.0 pydantic-settings>=2.6,<3.0
+8
View File
@@ -8,10 +8,16 @@ alembic==1.18.4
# via -r requirements.in # via -r requirements.in
annotated-types==0.7.0 annotated-types==0.7.0
# via pydantic # via pydantic
argon2-cffi==25.1.0
# via -r requirements.in
argon2-cffi-bindings==25.1.0
# via argon2-cffi
anyio==4.13.0 anyio==4.13.0
# via # via
# starlette # starlette
# watchfiles # watchfiles
cffi==2.0.0
# via argon2-cffi-bindings
click==8.3.2 click==8.3.2
# via uvicorn # via uvicorn
fastapi==0.115.14 fastapi==0.115.14
@@ -46,6 +52,8 @@ python-dotenv==1.2.2
# uvicorn # uvicorn
python-multipart==0.0.26 python-multipart==0.0.26
# via -r requirements.in # via -r requirements.in
pycparser==2.23
# via cffi
pyyaml==6.0.3 pyyaml==6.0.3
# via # via
# -r requirements.in # -r requirements.in
+1
View File
@@ -0,0 +1 @@
"""Project helper scripts."""
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from alembic.util.exc import CommandError
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_04_app_config_table"
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 _expected_head_revision(alembic_config: Config) -> str:
script = ScriptDirectory.from_config(alembic_config)
heads = script.get_heads()
if len(heads) != 1:
raise AppDatabaseAdoptionError(
f"Expected exactly one Alembic head for app DB, got {len(heads)}"
)
return heads[0]
def _is_known_revision(alembic_config: Config, revision: str) -> bool:
script = ScriptDirectory.from_config(alembic_config)
try:
return script.get_revision(revision) is not None
except CommandError:
return False
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)
alembic_config = _make_alembic_config(database_url)
expected_revision = _expected_head_revision(alembic_config)
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 != expected_revision:
raise AppDatabaseAdoptionError(
"App DB revision mismatch. Refusing to start the app: "
f"expected {expected_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)
expected_revision = _expected_head_revision(alembic_config)
if database_path.exists():
if _alembic_version_table_exists(database_path):
current_revision = _fetch_alembic_revision(database_path)
if current_revision == expected_revision:
return "already_managed"
if not _is_known_revision(alembic_config, current_revision):
raise AppDatabaseAdoptionError(
"App DB is already Alembic-managed but revision does not match "
f"a known migration revision: got {current_revision}"
)
command.upgrade(alembic_config, "head")
return "upgraded"
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.")
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.")
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More