Compare commits
75 Commits
32cc6847fd
..
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a3aaea933 | |||
| bf7fd71a21 | |||
| 962ba26c7c | |||
| da236643f2 | |||
| bd09523e94 | |||
| 53f1245d83 | |||
| 51f712f602 | |||
| f8b1e5fc71 | |||
| a9830c42d8 | |||
| 8aa7316b26 | |||
| 32d93bba2a | |||
| 0d988a9b28 | |||
| ef7ea6b971 | |||
| 6cc6382515 | |||
| ef2bd3c9c5 | |||
| cc2c02a2e2 | |||
| b2e26f0b17 | |||
| 8975acc48b | |||
| 6cfeb2b865 | |||
| dba9e28540 | |||
| 2bc5d6ea9a | |||
| 3ec663e138 | |||
| 048414c5cb | |||
| 9ce3f2a0b8 | |||
| 0fba7cfe11 | |||
| d8303eaa3d | |||
| 8da1f13e60 | |||
| de77019ce3 | |||
| c2b1b7b751 | |||
| 3628ac51e5 | |||
| 1756192270 | |||
| 66ec9979cc | |||
| c1a5d7a425 | |||
| 1e0b235cef | |||
| a337b06c94 | |||
| 1cbe6c46d2 | |||
| 2f634006d2 | |||
| dc624bb7e5 | |||
| af8c602988 | |||
| 0d898e09f2 | |||
| 3d3c2bcc57 | |||
| bc8dd062d5 | |||
| 427a491380 | |||
| b359bbe3bf | |||
| 636bb2b80b | |||
| eda49489e0 | |||
| 779e160b95 | |||
| 3ea3498e58 | |||
| 5a420bd37b | |||
| a24e402d47 | |||
| 8565534b73 | |||
| 4acdd2dc60 | |||
| c9af7530e5 | |||
| a76d6bfb71 | |||
| 35aee79d93 | |||
| b9e7f51d51 | |||
| 94747c75dd | |||
| 7978a7e1e1 | |||
| e9e2034d30 | |||
| aae8ca3b87 | |||
| 1805d5d8ea | |||
| 795c84f177 | |||
| 1ff426d2e9 | |||
| fe0409dafe | |||
| 982af62f4f | |||
| 179aae264e | |||
| 3f7c9e43d9 | |||
| e1aad408ab | |||
| 044b47c573 | |||
| e334df992f | |||
| 151ad46275 | |||
| eb487ccb46 | |||
| d0dc8e893a | |||
| 1a2f9c75d9 | |||
| 8aeb0723c1 |
@@ -8,3 +8,6 @@ data
|
||||
openapi
|
||||
src
|
||||
|
||||
# Frontend host build artifacts — built inside the node stage, not needed from context
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
|
||||
+25
-9
@@ -1,14 +1,30 @@
|
||||
# Required: bootstrap and core app settings.
|
||||
# These values should be set before the container starts.
|
||||
APP_NAME=Home Automation Backend (Python)
|
||||
APP_ENV=development
|
||||
APP_DEBUG=true
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db
|
||||
POO_DATABASE_URL=sqlite:///./data/pooRecorder.db
|
||||
APP_ENV=production
|
||||
APP_HOSTNAME=home-automation.example.com
|
||||
APP_DATABASE_URL=sqlite:////app/data/app.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_SECRET=
|
||||
TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback
|
||||
TICKTICK_TOKEN=
|
||||
HOME_ASSISTANT_BASE_URL=http://localhost:8123
|
||||
HOME_ASSISTANT_AUTH_TOKEN=
|
||||
HOME_ASSISTANT_ACTION_TASK_PROJECT_ID=
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,49 @@
|
||||
name: frontend
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Check codegen is in sync
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm run codegen
|
||||
git diff --exit-code src/api/schema.d.ts
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Type-check
|
||||
working-directory: frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
working-directory: frontend
|
||||
run: npm run test
|
||||
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
@@ -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
-1
@@ -5,4 +5,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
data/
|
||||
openapi/
|
||||
review-notes/
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# CLAUDE.md — Home Automation Backend
|
||||
|
||||
本文件每次会话自动加载。它定义本项目的**工作流程、文档位置、commit 规范**。请在动手前先读完。
|
||||
|
||||
## 项目速览
|
||||
|
||||
- 个人用 home-automation 后端:**FastAPI + SQLite + SQLAlchemy + Alembic**,服务端模板(Jinja,M2 将换成 React SPA)。
|
||||
- 单 admin 鉴权(Argon2 + server-side session cookie),runtime config 落 `app_config` 表。
|
||||
- 模块:public IPv4 monitor、SMTP 通知、location recorder、poo recorder、Home Assistant in/out、TickTick OAuth。
|
||||
- 已发布 `v1.0.3`。下一阶段方向:**M1 单库化 → M2 React 前端 → M3 token/移动端(远期,M2 后再说)**。
|
||||
- **当前现实**:在 M1 完成前仍是**三个独立 SQLite 库**(app / location / poo),三套 DeclarativeBase、三条 Alembic 链。不要假设已经单库——以代码现状为准。
|
||||
- 明确不做:Notion 模块。
|
||||
|
||||
## 文档地图与「开工前必读」
|
||||
|
||||
文档都在 `docs/`:
|
||||
|
||||
| 路径 | 作用 |
|
||||
| --- | --- |
|
||||
| `docs/roadmap.md` | 全局规划与里程碑总览 |
|
||||
| `docs/design/README.md` | **协作契约**:任务卡格式、原子任务定义、校验闸门、数据安全红线 |
|
||||
| `docs/design/m1-db-consolidation.md` | M1 原子任务(含真实代码现状盘点 + 人工 runbook) |
|
||||
| `docs/design/m2-frontend-v2.md` | M2 原子任务 + API 契约 + 前端校验闸门 |
|
||||
| `docs/design/m3-token-mobile.md` | M3(远期,暂缓) |
|
||||
| `docs/*.md`(auth / public-ip-monitor / location-recorder …) | 各模块说明,按需读 |
|
||||
|
||||
**开工时读取顺序**:
|
||||
1. `docs/design/README.md`(每轮都读,它是流程与验收的共同契约)。
|
||||
2. 本轮对应的 milestone 文档(如 `docs/design/m1-db-consolidation.md`),定位要做的任务卡。
|
||||
3. 任务卡 `Files` 列出的源文件 + 该模块的 `docs/*.md`(按需)。
|
||||
4. `docs/roadmap.md` 仅在需要全局视角时读。
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 实现模式(由用户的提示词决定)
|
||||
|
||||
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
|
||||
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
|
||||
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent 并指定模型**时,才用指定模型起 implementer sub-agent,按任务依赖顺序跑完整条链。
|
||||
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时用用户**指定的模型**(Agent 工具的 `model` 覆盖)。
|
||||
|
||||
### 角色(Orchestrator → Implementer → Reviewer)
|
||||
|
||||
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`。
|
||||
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
|
||||
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
|
||||
|
||||
#### Reviewer 盲审纪律(M1 教训)
|
||||
|
||||
M1 里 review **从未触发过一次 rework**,根因是 orchestrator 把自己的结论 / 辩护喂给了 reviewer,造成 context bleed、review 沦为橡皮图章。所以:
|
||||
|
||||
- reviewer 必须**冷启动(Clear-Agent)、最小化喂料**——spawn prompt 只给:① 任务卡(`Acceptance criteria` + `Reviewer checklist`)、② 对应的 `review-notes/<task>-impl|rework-<n>.md` 路径、③ 要审的 diff / commit 范围。
|
||||
- **不要**在 prompt 里塞 orchestrator 自己的判断、"我觉得没问题"、对实现选择的辩护,或上一轮 reviewer 的倾向性结论。让它**独立得出结论、独立重跑校验闸门**。
|
||||
- 事后另起的整库**独立盲审**(如对抗复审)同理:Clear-Agent、最小上下文,把它当"**外部审计**"而非"确认自己没错"。
|
||||
|
||||
### 校验闸门(每个任务结束都要全绿)
|
||||
|
||||
根目录、激活 `.venv` 后:
|
||||
```bash
|
||||
pytest # 权威闸门(CI 跑的就是它)
|
||||
ruff check . # line-length=100
|
||||
python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路由/schema 才需要,且产物须入库
|
||||
```
|
||||
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
|
||||
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
|
||||
|
||||
### 构建上下文完整性(M1 Dockerfile 教训)
|
||||
|
||||
`docker build` **不在 pytest/ruff 闸门里**——M1 删了 `alembic_location/poo` 后忘了同步 `Dockerfile` 的 `COPY`,单元闸门全绿却把坏掉的镜像构建一路漏到 release tag。所以:
|
||||
|
||||
- 任务**删除 / 移动 / 重命名文件或目录**时,必须 grep 构建清单是否还在引用它们:`Dockerfile`(尤其 `COPY` 源)、`docker/`、`*.ini`、CI workflow、`requirements*.txt` 等。
|
||||
- 已有回归测试 `tests/test_deployment.py::test_dockerfile_copy_sources_exist` 守"Dockerfile `COPY` 源必须存在于构建上下文";新增 / 改动 `COPY` 时确保它仍覆盖得到。
|
||||
- Reviewer 审"删 / 移文件"类任务时,**必须顺带核对构建清单引用**,把它当 acceptance 的一部分。
|
||||
|
||||
## 每轮简报(`review-notes/`)
|
||||
|
||||
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
|
||||
|
||||
- **实现 / 返工简报**:每轮实现完成后(无论首次实现还是返工),写一份。文件名建议 `<task-id>-impl-<n>.md` / `<task-id>-rework-<n>.md`(如 `M1-T03-impl-1.md`、`M1-T03-rework-1.md`)。至少包含:
|
||||
1. **本轮修改的具体内容**(改了哪些文件、做了什么、为什么)。
|
||||
2. **自动化测试结果**(`pytest` / `ruff` / 前端闸门的实际输出或结论,通过/失败逐项写清)。
|
||||
3. **若需人工 walkthrough**:写明具体步骤(怎么启动、点哪里、预期看到什么);若无需人工验证,明确写"无需人工 walkthrough"。
|
||||
- **review 简报**:每轮 review 后写一份,文件名建议 `<task-id>-review-<n>.md`(如 `M1-T03-review-1.md`)。至少包含:评审结论(`PASS` 或带编号的返工清单)、对照任务卡 `Acceptance criteria` + `Reviewer checklist` 的逐条核对、reviewer 独立重跑校验闸门的结果。
|
||||
|
||||
**用途**:① reviewer 审核时参考对应的实现简报;② implementer 返工时参考对应的 review 简报;③ 人类(用户)通读这些简报确认有无问题。简报之间用文件名里的 `<task-id>` 与轮次 `<n>` 对应起来。
|
||||
|
||||
### Orchestrator 派发契约(让简报真正被读到)
|
||||
|
||||
**关键**:sub-agent 冷启动、不继承主线上下文,**不会因为本文件提到简报就自动去读**对应文件。简报能流转,靠的是 orchestrator(主线)在**每次 spawn 时把路径显式写进 prompt**,而不是被动约定。所以派发时必须做到:
|
||||
|
||||
- **显式告诉它「先读哪个简报」**:
|
||||
- 派 implementer 做**首次实现** → 传任务卡位置(milestone 文档路径 + task id);无前置简报。
|
||||
- 派 implementer 做**返工** → 必须传对应的 `review-notes/<task>-review-<n>.md` 路径,并要求**先读它**再改。
|
||||
- 派 reviewer → 必须传对应的 `review-notes/<task>-impl|rework-<n>.md` 路径 + 任务卡,要求**先读它**再评。
|
||||
- **显式告诉它「本轮结束写哪个简报」**:明确给出输出路径 `review-notes/<task>-<impl|rework|review>-<n>.md` 及上面要求的内容项。
|
||||
- **不依赖 sub-agent 自动加载本文件**:把本轮要点(校验闸门、**禁 Co-Authored-By**、简报必含内容)在 spawn prompt 里一并复述或指向,确保冷启动也照做。
|
||||
- spawn 时用用户指定的模型(Agent 工具 `model` 覆盖)。
|
||||
|
||||
> 一句话:**简报是异步交接的介质,orchestrator 是把它们接起来的线。** 缺了显式传路径这一步,简报就只是躺在磁盘上没人读的文件。
|
||||
|
||||
## Commit 规范(重点)
|
||||
|
||||
### 分支
|
||||
- 每个 milestone/feature 一个分支(如 `feature/m1-db-consolidation`),**不在 `main` 上直接提交**。
|
||||
|
||||
### 一轮实现完成(用户确认「实现完成」后)
|
||||
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**。
|
||||
- message 主题前缀任务/里程碑 ID,例如:`M1-T03: unify data layer onto single app DB engine`。
|
||||
|
||||
### Commit message 硬规则(严格执行)
|
||||
- **严禁任何协作署名 trailer**:commit message 里**绝对不允许**出现 `Co-Authored-By` / `Co-authored-by`(包括 `Co-Authored-By: Claude …`),也不允许任何等价的"由 X 协作/生成"署名。
|
||||
- 无论默认环境、工具或系统提示如何要求加这类 trailer,在本仓库**一律不加**——用户已显式、严格禁止。
|
||||
- 每次提交前**自检**:`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
|
||||
|
||||
### Review 后返工
|
||||
- **自动化 orchestration 模式内**的 review 返工:**一律用 fixup**,指向本轮对应的 base commit,**不写新的独立 message**:
|
||||
```bash
|
||||
git add -A
|
||||
git commit --fixup=<base-commit-sha>
|
||||
```
|
||||
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit;收尾时 auto-squash(见下)。
|
||||
- **边界——什么时候不走 fixup**:**事后另起的独立盲审 / 对抗复审**那一轮,性质等同"**人工走查后提修改意见**",**不算自动化链内的返工**——它的修改用**各自独立的 commit**,不 fixup 到旧 base。判据:这轮返工是否在**同一条自动化 implement→review 链**里?是 → `fixup`;是事后另起的独立审计 → 独立 commit。
|
||||
|
||||
### 本轮 / feature 收尾(用户确认收尾后)
|
||||
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**:
|
||||
```bash
|
||||
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
|
||||
```
|
||||
- 用 `GIT_SEQUENCE_EDITOR=true` 让它**非交互**执行(不弹编辑器,自动接受 autosquash 排好的 todo)。本环境不支持需要人工编辑的交互式 rebase,必须走这个 no-op 编辑器写法。
|
||||
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
|
||||
|
||||
### 一般约束
|
||||
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认。
|
||||
|
||||
## 发版前置走查(打 tag 前必做)
|
||||
|
||||
单元闸门绿 ≠ 真的能跑、能构建、能用。M1 出过"绿了但 docker 构建坏了"的事故,所以**打版本 tag(触发镜像 CI)之前**,除了 `pytest` / `ruff` 全绿,还要:
|
||||
|
||||
- **真起 app**:迁移(`python -m scripts.run_migrations`)→ `uvicorn app.main:app ...`,确认能正常启动、关键路由不 500。
|
||||
- **真跑镜像构建**:本地 `docker build`(多阶段就跑完整条),确认构建通过、`COPY` 源都在。
|
||||
- **关键功能人工瞄一眼**:尤其前端 / 可视化类(M2 的热力图、首页地图)——自动闸门判断不了"渲染对不对、UX 顺不顺",这部分**靠看跑起来的 app,不靠读代码**。
|
||||
- 上述任一不过 → **不打 tag**。tag 一旦 push 会触发 docker 镜像 CI / 对外发布,属对外操作,**先确认**。
|
||||
|
||||
## 数据安全红线(不可违反)
|
||||
|
||||
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
|
||||
- 涉及历史数据的迁移**先在备份副本上演练**;迁移脚本必须幂等且搬完对账行数。
|
||||
- Review 时只要发现"删文件 / drop 有数据的表 / truncate"出现在自动化任务里,直接判返工。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 环境
|
||||
python -m venv .venv && source .venv/bin/activate && pip install -r dev-requirements.txt
|
||||
# 迁移(初始化/适配 DB)
|
||||
python -m scripts.run_migrations
|
||||
# 起服务
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
# 测试 / lint / OpenAPI 导出
|
||||
pytest
|
||||
ruff check .
|
||||
python scripts/export_openapi.py
|
||||
```
|
||||
+24
-2
@@ -1,3 +1,20 @@
|
||||
# Stage 1: build the React SPA.
|
||||
# Pin to the native build host ($BUILDPLATFORM) so the Node/V8 build never runs
|
||||
# under QEMU during multi-arch builds — emulated Node crashes V8's baseline JIT
|
||||
# (SIGTRAP / exit 133 on `npm ci`). The dist/ output is static JS/CSS, i.e.
|
||||
# architecture-independent, so building it once and COPYing it into each
|
||||
# target-arch runtime stage is both correct and avoids the emulator entirely.
|
||||
FROM --platform=$BUILDPLATFORM node:22-slim AS frontend-build
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: python runtime (no node)
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
@@ -9,12 +26,17 @@ COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY alembic ./alembic
|
||||
COPY alembic.ini ./
|
||||
COPY alembic_app ./alembic_app
|
||||
COPY alembic_app.ini ./
|
||||
COPY scripts ./scripts
|
||||
COPY docker ./docker
|
||||
COPY README.md ./
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy the built SPA dist from the frontend-build stage
|
||||
COPY --from=frontend-build /frontend/dist ./frontend/dist
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,58 +1,59 @@
|
||||
# Home Automation Backend
|
||||
|
||||
这是当前 `home-automation` 项目的 Python 重构基础骨架。当前仓库仍保留 Go 版本作为事实基线,而这个 Python 部分的目标是为后续逐模块迁移提供稳定工程基础。
|
||||
这是当前 `home-automation` 项目的首个 Python 版本。
|
||||
|
||||
为便于清理仓库,重构开始前就存在的 Go 实现和相关资产已经统一移动到 `legacy/go-backend/`。这样在 Python 重构完成后,可以按目录整体删除旧实现。
|
||||
当前系统已经包含:
|
||||
|
||||
当前阶段只包含:
|
||||
- FastAPI Web 应用(React SPA 前端 + JSON API)
|
||||
- SQLite + SQLAlchemy + Alembic 的单库结构
|
||||
- username/password + server-side session 鉴权
|
||||
- runtime config 页面与 app DB 持久化
|
||||
- public IPv4 monitor、历史持久化与定时检查
|
||||
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
|
||||
- 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 在 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 文件
|
||||
- `poo` 模块未来也将使用自己的 DB 文件
|
||||
- auth(单个 admin 用户、server-side session)
|
||||
- runtime config 持久化(`app_config` 表)
|
||||
- public IPv4 当前状态与变化历史
|
||||
- location 记录(`location` 表)
|
||||
- poo 记录(`poo_records` 表)
|
||||
|
||||
当前阶段明确不借这次重构把两个 DB 合并。配置层已经显式反映这一点:
|
||||
配置层只保留一个数据库环境变量:
|
||||
|
||||
- `LOCATION_DATABASE_URL`
|
||||
- `POO_DATABASE_URL`
|
||||
- `APP_DATABASE_URL`
|
||||
|
||||
目前真正接入的是 `location` 对应的数据库;`poo` 先保留配置占位,等模块迁入时再接上。
|
||||
`app.db` 不会在应用启动时自动创建,需要先运行:
|
||||
|
||||
```bash
|
||||
python -m scripts.run_migrations
|
||||
```
|
||||
|
||||
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含 `location` / `poo_records` 表)。
|
||||
|
||||
## 当前目录
|
||||
|
||||
Python 骨架的主要目录如下:
|
||||
主要目录如下:
|
||||
|
||||
- `app/`: FastAPI 应用代码
|
||||
- `alembic/`: Alembic migration 环境
|
||||
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
|
||||
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
|
||||
- `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表)
|
||||
- `tests/`: pytest 测试
|
||||
- `docs/`: 架构说明与迁移文档
|
||||
- `docs/`: 当前系统说明文档
|
||||
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
||||
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
|
||||
|
||||
## 依赖管理
|
||||
|
||||
@@ -99,7 +100,13 @@ pip install -r dev-requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. 启动服务
|
||||
3. 初始化数据库
|
||||
|
||||
```bash
|
||||
python -m scripts.run_migrations
|
||||
```
|
||||
|
||||
4. 启动服务
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
@@ -107,27 +114,291 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
|
||||
启动后可访问:
|
||||
|
||||
- 应用首页:`http://localhost:8000/`
|
||||
- 应用首页(React SPA):`http://localhost:8000/`
|
||||
- 健康检查:`http://localhost:8000/status`
|
||||
- Swagger UI:`http://localhost:8000/docs`
|
||||
- ReDoc:`http://localhost:8000/redoc`
|
||||
|
||||
## 数据库与 Alembic
|
||||
## 前端 v2(React SPA)
|
||||
|
||||
当前默认仍使用 SQLite,但要明确区分两个数据库文件:
|
||||
M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。
|
||||
|
||||
- Location DB:`sqlite:///./data/locationRecorder.db`
|
||||
- Poo DB:`sqlite:///./data/pooRecorder.db`
|
||||
- 数据目录:`./data/`
|
||||
### 技术栈
|
||||
|
||||
初始化 migration 环境后,可继续添加模型并生成迁移:
|
||||
- **Vite + React + TypeScript + Mantine**(组件库)
|
||||
- **TanStack Query**(数据请求/缓存)
|
||||
- **Leaflet / react-leaflet**(地图与热力图)
|
||||
- **openapi-typescript + openapi-fetch**(类型化 API client,由 `openapi/openapi.json` 生成)
|
||||
|
||||
### 本地开发(前端)
|
||||
|
||||
前端开发服务器会把 `/api`、`/location`、`/poo`、`/public-ip`、`/homeassistant`、`/ticktick`、`/status` 等路径代理到后端 FastAPI(`:8000`)。
|
||||
|
||||
```bash
|
||||
alembic revision --autogenerate -m "init tables"
|
||||
alembic upgrade head
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # 启动 Vite dev server(默认 :5173),代理后端
|
||||
```
|
||||
|
||||
当前 Alembic 只接管 `location` 这条链路;`poo` 相关数据库与 migration 还没有迁入。
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build # 产出 frontend/dist
|
||||
```
|
||||
|
||||
FastAPI 启动时若 `frontend/dist/index.html` 存在,则自动挂载该目录,并对非 `/api` 路径做 SPA fallback(返回 `index.html`)。该路径可通过环境变量 `SPA_DIST_DIR` 覆盖(默认值为 `frontend/dist`,与多阶段 Dockerfile 中 `COPY` 到 `/app/frontend/dist` 一致)。
|
||||
|
||||
### 类型化 API Client
|
||||
|
||||
前端 API client 由后端 OpenAPI schema 自动生成:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run codegen # 从 ../openapi/openapi.json 生成 src/api/schema.d.ts
|
||||
```
|
||||
|
||||
生成物(`src/api/schema.d.ts`)已提交入库,CI 会校验它与 `openapi/openapi.json` 保持同步。
|
||||
|
||||
### 前端校验闸门
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint # ESLint
|
||||
npm run typecheck # TypeScript 类型检查
|
||||
npm run test # Vitest 单元测试
|
||||
npm run build # 构建,确认产出 dist
|
||||
```
|
||||
|
||||
## 数据库与 Alembic
|
||||
|
||||
当前使用单一 SQLite 数据库文件:
|
||||
|
||||
- App DB:`sqlite:///./data/app.db`
|
||||
- 数据目录:`./data/`
|
||||
|
||||
所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
||||
|
||||
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||
- 统一 migration job:`python -m scripts.run_migrations`
|
||||
- App DB 接管 / 初始化:`python scripts/app_db_adopt.py`
|
||||
|
||||
历史 location / poo 数据(旧版本遗留的独立 DB 文件)已通过以下脚本一次性迁移至 `app.db`(幂等,不删除旧文件):
|
||||
|
||||
```bash
|
||||
python -m scripts.migrate_legacy_data
|
||||
```
|
||||
|
||||
## 基础鉴权
|
||||
|
||||
当前项目提供一个单用户 admin 鉴权层,用于保护配置页面与管理能力。
|
||||
|
||||
- 认证模型:`username/password`
|
||||
- 会话模型:server-side session + cookie
|
||||
- 当前受保护入口:React SPA(`/` 等客户端路由)调用 `/api/*` JSON 端点
|
||||
- 当前公开页面:`/login`(SPA 登录页)
|
||||
- 当前公开 API:裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
|
||||
|
||||
安全实现的当前边界:
|
||||
|
||||
- 密码使用 Argon2 做哈希存储
|
||||
- session cookie 使用 `HttpOnly`
|
||||
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
|
||||
- `SameSite=Lax`
|
||||
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
|
||||
|
||||
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
|
||||
|
||||
- `AUTH_BOOTSTRAP_USERNAME`
|
||||
- `AUTH_BOOTSTRAP_PASSWORD`
|
||||
|
||||
创建初始 admin 用户。当前默认就是:
|
||||
|
||||
- username: `admin`
|
||||
- password: `admin`
|
||||
|
||||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
||||
|
||||
React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`):
|
||||
|
||||
- `/login`:登录页
|
||||
- `/`:首页(地图热力图主视图)
|
||||
- `/config`:配置页(取代原 Jinja `/config`)
|
||||
- `/records`:记录管理列表页
|
||||
|
||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`)。
|
||||
|
||||
## Config 持久化
|
||||
|
||||
当前 config 页面不会把修改写回 `.env`。
|
||||
|
||||
当前原则是:
|
||||
|
||||
- `.env` 只负责 bootstrap / fallback
|
||||
- app 启动先从 `.env` 读取数据库地址等基础配置
|
||||
- 请求期读取配置时,优先使用 app DB 中的 `app_config` 表
|
||||
- 如果数据库里没有对应值,再 fallback 到 `.env`
|
||||
|
||||
这意味着:
|
||||
|
||||
- app DB 地址(`APP_DATABASE_URL`)仍然属于 bootstrap 范畴
|
||||
- 运行时可编辑配置主要通过 `app_config` 表持久化
|
||||
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
|
||||
- 登录密码仍然单独使用 Argon2 哈希,不走 config 表明文存储
|
||||
|
||||
当前已经接入 config 页面的运行时配置包括:
|
||||
|
||||
- 基础系统配置
|
||||
- auth cookie 相关配置
|
||||
- SMTP 基础配置
|
||||
- TickTick OAuth 配置
|
||||
- Home Assistant 配置
|
||||
|
||||
其中 SMTP password 与其他 secret 字段一致:
|
||||
|
||||
- 页面不明文回显
|
||||
- 留空提交时保留旧值
|
||||
- 用于测试发信与自动通知时不会写入响应
|
||||
|
||||
## Public IPv4 Monitor
|
||||
|
||||
当前系统已经提供最小可用的 public IPv4 monitor:
|
||||
|
||||
- 使用单一 provider 检查当前公网 IPv4
|
||||
- 将状态与变化历史持久化到 app DB
|
||||
- 提供受保护的手动检查入口:`GET /public-ip/check`
|
||||
- 启动时注册 APScheduler job,默认每 4 小时检查一次
|
||||
|
||||
当前 app DB 中与此功能相关的新表:
|
||||
|
||||
- `public_ip_state`
|
||||
- `public_ip_history`
|
||||
|
||||
状态语义如下:
|
||||
|
||||
- `first_seen`:首次发现当前公网 IPv4
|
||||
- `unchanged`:与上次状态一致
|
||||
- `changed`:公网 IPv4 发生变化
|
||||
- `error`:provider 请求失败或返回无效值
|
||||
|
||||
## SMTP 与邮件通知
|
||||
|
||||
当前系统已经提供最小可用的 SMTP 能力:
|
||||
|
||||
- SMTP 配置可在 React SPA `/config` 页面填写并保存到 `app_config`(通过 `PUT /api/config`)
|
||||
- 可通过 config 页面发送测试邮件(`POST /api/config/smtp/test`)
|
||||
- 邮件 `From` 头支持显示名,例如 `Home Automation <sender@example.com>`
|
||||
|
||||
当前 SMTP 配置项包括:
|
||||
|
||||
- `SMTP_ENABLED`
|
||||
- `SMTP_HOST`
|
||||
- `SMTP_PORT`
|
||||
- `SMTP_USERNAME`
|
||||
- `SMTP_PASSWORD`
|
||||
- `SMTP_FROM_NAME`
|
||||
- `SMTP_FROM_ADDRESS`
|
||||
- `SMTP_TO_ADDRESS`
|
||||
- `SMTP_USE_STARTTLS`
|
||||
|
||||
当前 public IPv4 monitor 已与 SMTP sender 接通,但只处理一个很小的通知场景:
|
||||
|
||||
- 当 public IPv4 check 结果为 `changed` 时,自动发送一封英文纯文本邮件
|
||||
|
||||
以下情况不会发邮件:
|
||||
|
||||
- `first_seen`
|
||||
- `unchanged`
|
||||
- `error`
|
||||
|
||||
当前通知邮件内容固定,不提供模板系统,正文会包含:
|
||||
|
||||
- previous IP
|
||||
- current IP
|
||||
- detected time
|
||||
|
||||
手动测试时,如果需要再次模拟一次 IP 变化,可以临时修改 `public_ip_state.current_ipv4` 为一个保留测试地址,然后再次调用 `GET /public-ip/check`。
|
||||
|
||||
## OpenAPI
|
||||
|
||||
可使用下面的脚本重新导出当前 API 定义:
|
||||
|
||||
```bash
|
||||
python scripts/export_openapi.py
|
||||
```
|
||||
|
||||
导出结果会写入:
|
||||
|
||||
- `openapi/openapi.json`
|
||||
- `openapi/openapi.yaml`
|
||||
|
||||
## Docker Compose
|
||||
|
||||
当前默认 Compose 服务名为 `app`,容器名固定为 `home-automation-app`。
|
||||
|
||||
当前 Compose 分成两层:
|
||||
|
||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
|
||||
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
|
||||
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
|
||||
|
||||
本地开发启动方式(显式叠加 dev 层):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
```
|
||||
|
||||
dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
|
||||
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
|
||||
|
||||
如果要按生产方式直接从 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`。
|
||||
|
||||
## 运行测试
|
||||
|
||||
@@ -137,8 +408,16 @@ pytest
|
||||
|
||||
当前测试包含:
|
||||
|
||||
- app 基本启动测试
|
||||
- `/status` endpoint 测试
|
||||
- app 启动与 `/status` 检查
|
||||
- 登录 / session / 鉴权流程
|
||||
- runtime config 读写
|
||||
- public IPv4 monitor
|
||||
- SMTP 配置与测试发信
|
||||
- location / poo recorder 端点
|
||||
- Home Assistant inbound 集成
|
||||
- TickTick OAuth
|
||||
- 部署与迁移(`run_migrations`)
|
||||
- legacy 数据迁移脚本(`migrate_legacy_data`)
|
||||
|
||||
## OpenAPI 导出
|
||||
|
||||
@@ -153,6 +432,8 @@ python scripts/export_openapi.py
|
||||
- `openapi/openapi.json`
|
||||
- `openapi/openapi.yaml`
|
||||
|
||||
`openapi/` 当前纳入版本控制。接口发生变更时,应重新运行导出脚本并同步提交生成的 schema 文件。
|
||||
|
||||
## 容器启动
|
||||
|
||||
1. 准备环境变量文件
|
||||
@@ -175,18 +456,3 @@ SQLite 持久化目录:
|
||||
|
||||
- 本地 `./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)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
This directory contains the Alembic migration environment for the Python rewrite skeleton.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""location baseline
|
||||
|
||||
Revision ID: 20260419_01_location_baseline
|
||||
Revises:
|
||||
Create Date: 2026-04-19 00:00:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260419_01_location_baseline"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"location",
|
||||
sa.Column("person", sa.Text(), nullable=False),
|
||||
sa.Column("datetime", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.Float(), nullable=False),
|
||||
sa.Column("longitude", sa.Float(), nullable=False),
|
||||
sa.Column("altitude", sa.Float(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("person", "datetime"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("location")
|
||||
@@ -1,8 +1,8 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
script_location = alembic_app
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url = sqlite:///./data/locationRecorder.db
|
||||
sqlalchemy.url = sqlite:///./data/app.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
@@ -4,8 +4,12 @@ from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Location # noqa: F401
|
||||
from app.models.base import Base
|
||||
from app.db import Base
|
||||
from app.models.config import AppConfigEntry # noqa: F401
|
||||
from app.models.auth import AuthSession, AuthUser # noqa: F401
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState # noqa: F401
|
||||
from app.models.location import Location # noqa: F401
|
||||
from app.models.poo import PooRecord # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
@@ -14,8 +18,8 @@ if config.config_file_name is not None:
|
||||
|
||||
settings = get_settings()
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.location_database_url)
|
||||
if not configured_url or configured_url == "sqlite:///./data/app.db":
|
||||
config.set_main_option("sqlalchemy.url", settings.app_database_url)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -23,4 +23,3 @@ def upgrade() -> None:
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""public ip monitor tables
|
||||
|
||||
Revision ID: 20260429_05_public_ip_monitor
|
||||
Revises: 20260420_04_app_config_table
|
||||
Create Date: 2026-04-29 00:00:01.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260429_05_public_ip_monitor"
|
||||
down_revision: Union[str, None] = "20260420_04_app_config_table"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"public_ip_history",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("ipv4", sa.String(length=45), nullable=False),
|
||||
sa.Column("observed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("change_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("provider", sa.String(length=64), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_public_ip_history_observed_at",
|
||||
"public_ip_history",
|
||||
["observed_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"public_ip_state",
|
||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column("current_ipv4", sa.String(length=45), nullable=False),
|
||||
sa.Column("previous_ipv4", sa.String(length=45), nullable=True),
|
||||
sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_changed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_check_status", sa.String(length=32), nullable=False),
|
||||
sa.Column("last_check_error", sa.String(length=255), nullable=True),
|
||||
sa.Column("last_provider", sa.String(length=64), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("public_ip_state")
|
||||
op.drop_index("ix_public_ip_history_observed_at", table_name="public_ip_history")
|
||||
op.drop_table("public_ip_history")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""merge location and poo_records tables into app chain
|
||||
|
||||
Revision ID: 20260611_06_merge_location_poo_tables
|
||||
Revises: 20260429_05_public_ip_monitor
|
||||
Create Date: 2026-06-11 00:00:01.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260611_06_merge_location_poo_tables"
|
||||
down_revision: Union[str, None] = "20260429_05_public_ip_monitor"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"location",
|
||||
sa.Column("person", sa.Text(), nullable=False),
|
||||
sa.Column("datetime", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.REAL(), nullable=False),
|
||||
sa.Column("longitude", sa.REAL(), nullable=False),
|
||||
sa.Column("altitude", sa.REAL(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("person", "datetime"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"poo_records",
|
||||
sa.Column("timestamp", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False),
|
||||
sa.Column("latitude", sa.REAL(), nullable=False),
|
||||
sa.Column("longitude", sa.REAL(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("timestamp"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("poo_records")
|
||||
op.drop_table("location")
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Application package for the Python rewrite skeleton."""
|
||||
"""Application package for the home automation backend."""
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.config import Settings, get_settings
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.schemas.config import (
|
||||
ConfigField,
|
||||
ConfigResponse,
|
||||
ConfigSection,
|
||||
ConfigUpdateRequest,
|
||||
ConfigUpdateResponse,
|
||||
SmtpTestResponse,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.config_page import ConfigSaveError, build_config_sections, save_config_updates
|
||||
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_smtp_test_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-config"])
|
||||
|
||||
|
||||
def _sections_from_raw(sections_raw: list[dict]) -> list[ConfigSection]:
|
||||
result = []
|
||||
for section in sections_raw:
|
||||
fields = [ConfigField(**f) for f in section["fields"]]
|
||||
result.append(ConfigSection(name=section["name"], fields=fields))
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/config", response_model=ConfigResponse)
|
||||
def get_config(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> ConfigResponse:
|
||||
"""Return all configuration sections. Secret field values are masked (empty string)."""
|
||||
sections_raw = build_config_sections(db, settings)
|
||||
return ConfigResponse(sections=_sections_from_raw(sections_raw))
|
||||
|
||||
|
||||
@router.put("/config", response_model=ConfigUpdateResponse)
|
||||
def put_config(
|
||||
body: ConfigUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> ConfigUpdateResponse:
|
||||
"""
|
||||
Save configuration updates.
|
||||
|
||||
- Blank secret value keeps the existing stored value (no change).
|
||||
- Invalid values return 422 and nothing is written to the database.
|
||||
"""
|
||||
try:
|
||||
save_config_updates(db, body.updates, settings)
|
||||
except ConfigSaveError as exc:
|
||||
logger.warning("Rejected config update via API: %s", exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="invalid config submission",
|
||||
) from exc
|
||||
|
||||
# Re-read settings after save (save_config_updates clears the settings cache)
|
||||
refreshed_settings = get_settings()
|
||||
sections_raw = build_config_sections(db, refreshed_settings)
|
||||
return ConfigUpdateResponse(sections=_sections_from_raw(sections_raw))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/config/smtp/test",
|
||||
responses={
|
||||
200: {"model": SmtpTestResponse},
|
||||
400: {"model": SmtpTestResponse},
|
||||
502: {"model": SmtpTestResponse},
|
||||
},
|
||||
)
|
||||
def post_smtp_test(
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Send a test SMTP email using the current runtime settings.
|
||||
|
||||
Returns a structured result indicating success or the category of failure.
|
||||
Three possible outcomes:
|
||||
- 200 { "result": "success", "message": ... }
|
||||
- 400 { "result": "config-error", "message": ... } (EmailConfigurationError)
|
||||
- 502 { "result": "failed", "message": ... } (EmailDeliveryError)
|
||||
|
||||
SMTP credentials are never echoed in the response.
|
||||
"""
|
||||
try:
|
||||
send_smtp_test_email(settings)
|
||||
except EmailConfigurationError as exc:
|
||||
logger.warning("SMTP test rejected due to configuration: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
content={"result": "config-error", "message": str(exc)},
|
||||
)
|
||||
except EmailDeliveryError as exc:
|
||||
logger.warning("SMTP test delivery failed: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
content={"result": "failed", "message": str(exc)},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={"result": "success", "message": "Test email sent successfully."},
|
||||
)
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.dependencies import get_db
|
||||
from app.models.location import Location
|
||||
from app.models.poo import PooRecord
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
from app.schemas.data import (
|
||||
LocationRecord,
|
||||
LocationUpdateRequest,
|
||||
LocationsResponse,
|
||||
PooRecord as PooRecordSchema,
|
||||
PooResponse,
|
||||
PooUpdateRequest,
|
||||
PublicIPHistorySchema,
|
||||
PublicIPResponse,
|
||||
PublicIPStateSchema,
|
||||
)
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.location import delete_location, update_location
|
||||
from app.services.poo import delete_poo_record, update_poo_record
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-data"])
|
||||
|
||||
|
||||
@router.get("/locations", response_model=LocationsResponse)
|
||||
def get_locations(
|
||||
limit: int = Query(default=1000, ge=1, le=5000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
start: str | None = Query(default=None),
|
||||
end: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> LocationsResponse:
|
||||
"""
|
||||
Return location records with optional time-window filtering and pagination.
|
||||
|
||||
- ``start`` / ``end`` are ISO8601 strings; filtering is **inclusive** on both bounds.
|
||||
- Results are ordered by ``datetime`` ascending.
|
||||
- ``limit`` is capped at 5000 to prevent full-table exports.
|
||||
"""
|
||||
stmt = select(Location)
|
||||
|
||||
if start is not None:
|
||||
stmt = stmt.where(Location.datetime >= start)
|
||||
if end is not None:
|
||||
stmt = stmt.where(Location.datetime <= end)
|
||||
|
||||
stmt = stmt.order_by(Location.datetime).offset(offset).limit(limit)
|
||||
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
|
||||
items = [
|
||||
LocationRecord(
|
||||
person=row.person,
|
||||
datetime=row.datetime,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
altitude=row.altitude,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return LocationsResponse(items=items, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/poo", response_model=PooResponse)
|
||||
def get_poo(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> PooResponse:
|
||||
"""
|
||||
Return poo records ordered by timestamp descending (most recent first).
|
||||
|
||||
``limit`` is capped at 1000 to prevent full-table exports.
|
||||
"""
|
||||
stmt = (
|
||||
select(PooRecord)
|
||||
.order_by(desc(PooRecord.timestamp))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
rows = db.execute(stmt).scalars().all()
|
||||
|
||||
items = [
|
||||
PooRecordSchema(
|
||||
timestamp=row.timestamp,
|
||||
status=row.status,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return PooResponse(items=items, limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/public-ip", response_model=PublicIPResponse)
|
||||
def get_public_ip(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> PublicIPResponse:
|
||||
"""
|
||||
Return the current public IP state and recent history.
|
||||
|
||||
- ``state`` is ``null`` if no IP check has been performed yet.
|
||||
- ``history`` is ordered by ``observed_at`` descending (most recent first).
|
||||
- ``limit`` applies to the history list and is capped at 1000.
|
||||
"""
|
||||
state_row = db.execute(
|
||||
select(PublicIPState).where(PublicIPState.id == 1).limit(1)
|
||||
).scalar_one_or_none()
|
||||
|
||||
history_rows = db.execute(
|
||||
select(PublicIPHistory).order_by(desc(PublicIPHistory.observed_at)).limit(limit)
|
||||
).scalars().all()
|
||||
|
||||
state = PublicIPStateSchema.model_validate(state_row) if state_row is not None else None
|
||||
history = [PublicIPHistorySchema.model_validate(row) for row in history_rows]
|
||||
|
||||
return PublicIPResponse(state=state, history=history)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/locations/{person}/{datetime}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/locations/{person}/{datetime}", response_model=LocationRecord)
|
||||
def patch_location(
|
||||
person: str,
|
||||
datetime: str,
|
||||
body: LocationUpdateRequest = Body(default=LocationUpdateRequest()),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> LocationRecord:
|
||||
"""
|
||||
Update the non-PK fields of a single location record.
|
||||
|
||||
- ``person`` and ``datetime`` identify the row (composite PK) and are immutable.
|
||||
- Only ``latitude``, ``longitude``, and ``altitude`` may be updated.
|
||||
- Omitted body fields are left unchanged.
|
||||
- Returns **404** if the PK does not exist.
|
||||
"""
|
||||
row = update_location(
|
||||
db,
|
||||
person,
|
||||
datetime,
|
||||
latitude=body.latitude,
|
||||
longitude=body.longitude,
|
||||
altitude=body.altitude,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="location record not found",
|
||||
)
|
||||
return LocationRecord(
|
||||
person=row.person,
|
||||
datetime=row.datetime,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
altitude=row.altitude,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/locations/{person}/{datetime}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/locations/{person}/{datetime}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def delete_location_record(
|
||||
person: str,
|
||||
datetime: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> None:
|
||||
"""
|
||||
Delete the single location record identified by its composite PK.
|
||||
|
||||
- Exactly one row is deleted; **404** if the PK does not exist.
|
||||
- No batch delete / truncate path is available.
|
||||
"""
|
||||
deleted = delete_location(db, person, datetime)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="location record not found",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /api/poo/{timestamp}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/poo/{timestamp}", response_model=PooRecordSchema)
|
||||
def patch_poo(
|
||||
timestamp: str,
|
||||
body: PooUpdateRequest = Body(default=PooUpdateRequest()),
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> PooRecordSchema:
|
||||
"""
|
||||
Update the non-PK fields of a single poo record.
|
||||
|
||||
- ``timestamp`` is the PK and is immutable.
|
||||
- Only ``status``, ``latitude``, and ``longitude`` may be updated.
|
||||
- Omitted body fields are left unchanged.
|
||||
- Returns **404** if the PK does not exist.
|
||||
"""
|
||||
row = update_poo_record(
|
||||
db,
|
||||
timestamp,
|
||||
status=body.status,
|
||||
latitude=body.latitude,
|
||||
longitude=body.longitude,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="poo record not found",
|
||||
)
|
||||
return PooRecordSchema(
|
||||
timestamp=row.timestamp,
|
||||
status=row.status,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /api/poo/{timestamp}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/poo/{timestamp}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_model=None,
|
||||
)
|
||||
def delete_poo(
|
||||
timestamp: str,
|
||||
db: Session = Depends(get_db),
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> None:
|
||||
"""
|
||||
Delete the single poo record identified by its PK.
|
||||
|
||||
- Exactly one row is deleted; **404** if the PK does not exist.
|
||||
- No batch delete / truncate path is available.
|
||||
"""
|
||||
deleted = delete_poo_record(db, timestamp)
|
||||
if not deleted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="poo record not found",
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
|
||||
from app.dependencies import get_current_auth_session
|
||||
from app.services.auth import AuthenticatedSession
|
||||
|
||||
|
||||
def require_session(
|
||||
auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> AuthenticatedSession:
|
||||
if auth is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="authentication required",
|
||||
)
|
||||
return auth
|
||||
|
||||
|
||||
def require_csrf(
|
||||
_auth: AuthenticatedSession = Depends(require_session),
|
||||
x_csrf_token: str | None = Header(default=None, alias="X-CSRF-Token"),
|
||||
) -> None:
|
||||
if not x_csrf_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="missing CSRF token",
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.routes.api.deps import require_csrf, require_session
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings, get_db
|
||||
from app.schemas.session import (
|
||||
LoginRequest,
|
||||
PasswordChangeRequest,
|
||||
SessionResponse,
|
||||
SessionUser,
|
||||
)
|
||||
from app.services.auth import (
|
||||
AuthPasswordChangeError,
|
||||
AuthenticatedSession,
|
||||
authenticate_user,
|
||||
change_password,
|
||||
create_session,
|
||||
revoke_session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["api-session"])
|
||||
|
||||
|
||||
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
|
||||
return SessionResponse(
|
||||
user=SessionUser(
|
||||
username=auth.user.username,
|
||||
force_password_change=auth.user.force_password_change,
|
||||
),
|
||||
csrf_token=auth.session.csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/session", response_model=SessionResponse)
|
||||
def get_session(
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
) -> SessionResponse:
|
||||
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
|
||||
return _build_session_response(auth)
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=SessionResponse)
|
||||
def post_login(
|
||||
body: LoginRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
) -> SessionResponse:
|
||||
"""
|
||||
Authenticate with username and password.
|
||||
|
||||
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
|
||||
On failure, returns 401 with no cookie set.
|
||||
No X-CSRF-Token required (unauthenticated endpoint).
|
||||
"""
|
||||
user = authenticate_user(db, username=body.username, password=body.password)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid username or password",
|
||||
)
|
||||
|
||||
auth_session, raw_token = create_session(db, user=user, settings=settings)
|
||||
logger.info("Created API authenticated session for user '%s'", user.username)
|
||||
|
||||
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="/",
|
||||
)
|
||||
|
||||
auth = AuthenticatedSession(user=user, session=auth_session)
|
||||
return _build_session_response(auth)
|
||||
|
||||
|
||||
@router.post("/auth/logout")
|
||||
def post_logout(
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_app_settings),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Revoke the current session and clear the session cookie.
|
||||
Requires authentication and X-CSRF-Token header.
|
||||
Returns 204 No Content.
|
||||
"""
|
||||
revoke_session(db, auth_session=auth.session)
|
||||
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
|
||||
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
|
||||
return no_content
|
||||
|
||||
|
||||
@router.post("/auth/password")
|
||||
def post_change_password(
|
||||
body: PasswordChangeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
auth: AuthenticatedSession = Depends(require_session),
|
||||
_csrf: None = Depends(require_csrf),
|
||||
) -> Response:
|
||||
"""
|
||||
Change the current user's password.
|
||||
Requires authentication and X-CSRF-Token header.
|
||||
On AuthPasswordChangeError returns 400 with a generic message.
|
||||
On success, force_password_change becomes False (handled by the service).
|
||||
Returns 204 No Content.
|
||||
"""
|
||||
try:
|
||||
change_password(
|
||||
db,
|
||||
user=auth.user,
|
||||
current_password=body.current_password,
|
||||
new_password=body.new_password,
|
||||
confirm_password=body.confirm_password,
|
||||
)
|
||||
except AuthPasswordChangeError as exc:
|
||||
logger.info(
|
||||
"Rejected password change for user '%s': %s",
|
||||
auth.user.username,
|
||||
exc,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="password change failed",
|
||||
) from exc
|
||||
|
||||
logger.info("Password updated for user '%s'", auth.user.username)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,84 @@
|
||||
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_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),
|
||||
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=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)
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,6 +11,8 @@ from app.schemas.location import LocationRecordRequest
|
||||
from app.services.location import record_location
|
||||
|
||||
router = APIRouter(tags=["location"])
|
||||
logger = logging.getLogger(__name__)
|
||||
BAD_REQUEST_MESSAGE = "bad request"
|
||||
|
||||
|
||||
@router.post("/location/record")
|
||||
@@ -18,11 +21,15 @@ async def create_location_record(request: Request, db: Session = Depends(get_db)
|
||||
raw_payload = await request.body()
|
||||
data = json.loads(raw_payload)
|
||||
payload = LocationRecordRequest.model_validate(data)
|
||||
except json.JSONDecodeError as exc:
|
||||
return PlainTextResponse(str(exc), status_code=400)
|
||||
except ValidationError as exc:
|
||||
return PlainTextResponse(str(exc), status_code=400)
|
||||
|
||||
record_location(db, payload)
|
||||
return Response(status_code=200)
|
||||
except json.JSONDecodeError as exc:
|
||||
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:
|
||||
logger.warning("Rejected location request due to payload validation failure: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
except ValueError as exc:
|
||||
logger.warning("Rejected location request due to invalid numeric input: %s", exc)
|
||||
return PlainTextResponse(BAD_REQUEST_MESSAGE, status_code=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
return Response(status_code=status.HTTP_200_OK)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config import Settings
|
||||
from app.dependencies import get_app_settings
|
||||
|
||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse:
|
||||
context = {
|
||||
"app_name": settings.app_name,
|
||||
"app_env": settings.app_env,
|
||||
"notion_status": "Legacy scope, removed from the Python rewrite target.",
|
||||
}
|
||||
return templates.TemplateResponse(request, "home.html", context)
|
||||
@@ -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_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_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_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)
|
||||
@@ -0,0 +1,26 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.dependencies import get_db, get_current_auth_session
|
||||
from app.schemas.public_ip import PublicIPCheckResponse
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthenticatedSession
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
|
||||
router = APIRouter(tags=["public-ip"])
|
||||
|
||||
|
||||
@router.get("/public-ip/check", response_model=PublicIPCheckResponse)
|
||||
def run_public_ip_check(
|
||||
session: Session = Depends(get_db),
|
||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
||||
) -> PublicIPCheckResponse:
|
||||
if current_auth is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required")
|
||||
|
||||
result = check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
|
||||
return PublicIPCheckResponse(
|
||||
status=result.status,
|
||||
checked_at=result.checked_at,
|
||||
changed=result.changed,
|
||||
)
|
||||
@@ -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_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_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,
|
||||
)
|
||||
+44
-11
@@ -7,27 +7,42 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "Home Automation Backend (Python)"
|
||||
app_env: str = "development"
|
||||
app_env: str = "production"
|
||||
app_debug: bool = False
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
|
||||
location_database_url: str = "sqlite:///./data/locationRecorder.db"
|
||||
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
|
||||
app_hostname: str = "localhost:8000"
|
||||
app_database_url: str = "sqlite:///./data/app.db"
|
||||
|
||||
ticktick_client_id: str = ""
|
||||
ticktick_client_secret: str = ""
|
||||
ticktick_redirect_uri: str = ""
|
||||
ticktick_token: str = ""
|
||||
|
||||
home_assistant_base_url: str = ""
|
||||
home_assistant_auth_token: str = ""
|
||||
home_assistant_timeout_seconds: float = 1.0
|
||||
home_assistant_action_task_project_id: str = ""
|
||||
smtp_enabled: bool = False
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_username: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_name: str = ""
|
||||
smtp_from_address: str = ""
|
||||
smtp_to_address: str = ""
|
||||
smtp_use_starttls: bool = True
|
||||
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(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
@computed_field
|
||||
@@ -35,6 +50,22 @@ class Settings(BaseSettings):
|
||||
def is_development(self) -> bool:
|
||||
return self.app_env.lower() == "development"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def app_base_url(self) -> str:
|
||||
hostname = self.app_hostname.strip().rstrip("/")
|
||||
if not hostname:
|
||||
return ""
|
||||
scheme = "http" if self.is_development else "https"
|
||||
return f"{scheme}://{hostname}"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def ticktick_redirect_uri(self) -> str:
|
||||
if not self.app_base_url:
|
||||
return ""
|
||||
return f"{self.app_base_url}/ticktick/auth/code"
|
||||
|
||||
@staticmethod
|
||||
def _sqlite_path_from_url(database_url: str) -> Path | None:
|
||||
prefix = "sqlite:///"
|
||||
@@ -45,13 +76,15 @@ class Settings(BaseSettings):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def location_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.location_database_url)
|
||||
def app_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.app_database_url)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def poo_sqlite_path(self) -> Path | None:
|
||||
return self._sqlite_path_from_url(self.poo_database_url)
|
||||
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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from collections.abc import Generator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
@@ -10,18 +12,49 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||
connect_args: dict[str, object] = {}
|
||||
if settings.location_database_url.startswith("sqlite"):
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
return connect_args
|
||||
|
||||
engine = create_engine(settings.location_database_url, connect_args=connect_args)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
@lru_cache
|
||||
def _get_engine(database_url: str) -> Engine:
|
||||
engine = create_engine(database_url, connect_args=_build_connect_args(database_url))
|
||||
if database_url.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def _enable_sqlite_wal(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.close()
|
||||
|
||||
return engine
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _get_session_local(database_url: str) -> sessionmaker:
|
||||
engine = _get_engine(database_url)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
return _get_engine(get_settings().app_database_url)
|
||||
|
||||
|
||||
def get_session_local() -> sessionmaker:
|
||||
return _get_session_local(get_settings().app_database_url)
|
||||
|
||||
|
||||
def reset_db_caches() -> None:
|
||||
_get_session_local.cache_clear()
|
||||
_get_engine.cache_clear()
|
||||
|
||||
|
||||
def get_db_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
session_local = get_session_local()
|
||||
session = session_local()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
|
||||
+25
-4
@@ -1,15 +1,36 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db_session
|
||||
|
||||
|
||||
def get_app_settings() -> Settings:
|
||||
return get_settings()
|
||||
from app.integrations.homeassistant import HomeAssistantClient
|
||||
from app.integrations.ticktick import TickTickClient
|
||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||
from app.services.config_page import build_runtime_settings
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
yield from get_db_session()
|
||||
|
||||
|
||||
def get_app_settings(session: Session = Depends(get_db)) -> Settings:
|
||||
return build_runtime_settings(session, get_settings())
|
||||
|
||||
|
||||
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_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)
|
||||
|
||||
@@ -1,12 +1,108 @@
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SUCCESS_STATUS_CODES = {200, 201}
|
||||
|
||||
|
||||
class HomeAssistantConfigError(RuntimeError):
|
||||
"""Raised when required Home Assistant outbound configuration is missing."""
|
||||
|
||||
|
||||
class HomeAssistantRequestError(RuntimeError):
|
||||
"""Raised when a Home Assistant outbound HTTP request fails."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HomeAssistantClient:
|
||||
settings: Settings
|
||||
timeout_seconds: float | None = field(default=None)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.timeout_seconds is None:
|
||||
self.timeout_seconds = self.settings.home_assistant_timeout_seconds
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token)
|
||||
|
||||
def publish_sensor(
|
||||
self,
|
||||
*,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
attributes: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._require_config()
|
||||
if not entity_id:
|
||||
raise ValueError("entity_id must not be empty")
|
||||
|
||||
payload = {
|
||||
"entity_id": entity_id,
|
||||
"state": state,
|
||||
"attributes": attributes or {},
|
||||
}
|
||||
self._post_json(f"/api/states/{entity_id}", payload, operation="publish_sensor")
|
||||
|
||||
def trigger_webhook(self, *, webhook_id: str, body: Any) -> None:
|
||||
self._require_config()
|
||||
if not webhook_id:
|
||||
raise ValueError("webhook_id must not be empty")
|
||||
|
||||
self._post_json(f"/api/webhook/{webhook_id}", body, operation="trigger_webhook")
|
||||
|
||||
def _require_config(self) -> None:
|
||||
if self.is_configured():
|
||||
return
|
||||
raise HomeAssistantConfigError(
|
||||
"Home Assistant outbound integration is not configured. "
|
||||
"Set HOME_ASSISTANT_BASE_URL and HOME_ASSISTANT_AUTH_TOKEN."
|
||||
)
|
||||
|
||||
def _post_json(self, path: str, payload: Any, *, operation: str) -> None:
|
||||
url = self._build_url(path)
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = request.Request(url, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Authorization", f"Bearer {self.settings.home_assistant_auth_token}")
|
||||
|
||||
try:
|
||||
with request.urlopen(req, timeout=self.timeout_seconds) as response:
|
||||
status_code = response.getcode()
|
||||
except error.HTTPError as exc:
|
||||
logger.warning(
|
||||
"Home Assistant outbound %s failed with HTTP %s for %s",
|
||||
operation,
|
||||
exc.code,
|
||||
url,
|
||||
)
|
||||
raise HomeAssistantRequestError(
|
||||
f"Home Assistant outbound {operation} failed with HTTP {exc.code}"
|
||||
) from exc
|
||||
except error.URLError as exc:
|
||||
logger.warning("Home Assistant outbound %s failed for %s: %s", operation, url, exc)
|
||||
raise HomeAssistantRequestError(
|
||||
f"Home Assistant outbound {operation} failed to reach Home Assistant"
|
||||
) from exc
|
||||
|
||||
if status_code not in SUCCESS_STATUS_CODES:
|
||||
logger.warning(
|
||||
"Home Assistant outbound %s returned unexpected status %s for %s",
|
||||
operation,
|
||||
status_code,
|
||||
url,
|
||||
)
|
||||
raise HomeAssistantRequestError(
|
||||
f"Home Assistant outbound {operation} returned unexpected status {status_code}"
|
||||
)
|
||||
|
||||
def _build_url(self, path: str) -> str:
|
||||
base_url = self.settings.home_assistant_base_url.rstrip("/")
|
||||
quoted_path = parse.quote(path.lstrip("/"), safe="/")
|
||||
return f"{base_url}/{quoted_path}"
|
||||
|
||||
@@ -1,12 +1,301 @@
|
||||
from dataclasses import dataclass
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass, field, fields
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
TICKTICK_AUTH_URL = "https://ticktick.com/oauth/authorize"
|
||||
TICKTICK_TOKEN_URL = "https://ticktick.com/oauth/token"
|
||||
TICKTICK_OPEN_API_BASE_URL = "https://api.ticktick.com/open/v1"
|
||||
TICKTICK_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
|
||||
AUTH_SCOPE = "tasks:read tasks:write"
|
||||
|
||||
|
||||
class TickTickConfigError(RuntimeError):
|
||||
"""Raised when TickTick is missing required runtime configuration."""
|
||||
|
||||
|
||||
class TickTickAuthError(RuntimeError):
|
||||
"""Raised when TickTick OAuth state validation fails."""
|
||||
|
||||
|
||||
class TickTickRequestError(RuntimeError):
|
||||
"""Raised when a TickTick API request fails."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickProject:
|
||||
id: str
|
||||
name: str
|
||||
color: str | None = None
|
||||
sortOrder: int | None = None
|
||||
closed: bool | None = None
|
||||
groupId: str | None = None
|
||||
viewMode: str | None = None
|
||||
permission: str | None = None
|
||||
kind: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickTask:
|
||||
projectId: str
|
||||
title: str
|
||||
id: str | None = None
|
||||
isAllDay: bool | None = None
|
||||
completedTime: str | None = None
|
||||
content: str | None = None
|
||||
desc: str | None = None
|
||||
dueDate: str | None = None
|
||||
items: list[Any] | None = None
|
||||
priority: int | None = None
|
||||
reminders: list[str] | None = None
|
||||
repeatFlag: str | None = None
|
||||
sortOrder: int | None = None
|
||||
startDate: str | None = None
|
||||
status: int | None = None
|
||||
timeZone: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickAuthStateStore:
|
||||
pending_state: str | None = None
|
||||
|
||||
def issue_state(self) -> str:
|
||||
self.pending_state = secrets.token_hex(6)
|
||||
return self.pending_state
|
||||
|
||||
def matches_state(self, state: str) -> bool:
|
||||
return bool(self.pending_state and state == self.pending_state)
|
||||
|
||||
def consume_state(self, state: str) -> bool:
|
||||
if not self.pending_state or state != self.pending_state:
|
||||
return False
|
||||
self.pending_state = None
|
||||
return True
|
||||
|
||||
def clear(self) -> None:
|
||||
self.pending_state = None
|
||||
|
||||
|
||||
default_auth_state_store = TickTickAuthStateStore()
|
||||
|
||||
|
||||
def _coerce_dataclass_payload(model_type: type, payload: dict[str, Any]) -> Any:
|
||||
allowed_field_names = {item.name for item in fields(model_type)}
|
||||
filtered_payload = {
|
||||
key: value for key, value in payload.items() if key in allowed_field_names
|
||||
}
|
||||
return model_type(**filtered_payload)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TickTickClient:
|
||||
settings: Settings
|
||||
auth_state_store: TickTickAuthStateStore = field(default_factory=lambda: default_auth_state_store)
|
||||
timeout_seconds: float = 10.0
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret)
|
||||
return bool(self._client_id() and self._client_secret())
|
||||
|
||||
def has_token(self) -> bool:
|
||||
return bool(self.settings.ticktick_token)
|
||||
|
||||
def build_authorization_url(self) -> str:
|
||||
self._require_auth_config()
|
||||
state = self.auth_state_store.issue_state()
|
||||
params = parse.urlencode(
|
||||
{
|
||||
"client_id": self._client_id(),
|
||||
"response_type": "code",
|
||||
"redirect_uri": self._redirect_uri(),
|
||||
"state": state,
|
||||
"scope": AUTH_SCOPE,
|
||||
}
|
||||
)
|
||||
return f"{TICKTICK_AUTH_URL}?{params}"
|
||||
|
||||
def exchange_authorization_code(self, *, code: str, state: str) -> str:
|
||||
self._require_auth_config()
|
||||
if not code:
|
||||
raise ValueError("code must not be empty")
|
||||
if not state:
|
||||
raise ValueError("state must not be empty")
|
||||
if not self.auth_state_store.matches_state(state):
|
||||
raise TickTickAuthError("Invalid state")
|
||||
|
||||
body = parse.urlencode(
|
||||
{
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"scope": AUTH_SCOPE,
|
||||
"redirect_uri": self._redirect_uri(),
|
||||
}
|
||||
).encode("utf-8")
|
||||
req = request.Request(TICKTICK_TOKEN_URL, data=body, method="POST")
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.add_header("Authorization", self._basic_auth_header())
|
||||
payload = self._send_json_request(req, operation="exchange_authorization_code")
|
||||
self.auth_state_store.clear()
|
||||
token = payload.get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise TickTickRequestError("TickTick token response did not include access_token")
|
||||
return token
|
||||
|
||||
def get_projects(self) -> list[TickTickProject]:
|
||||
self._require_token()
|
||||
payload = self._authorized_json_request(
|
||||
method="GET",
|
||||
path="/project/",
|
||||
operation="get_projects",
|
||||
)
|
||||
if not isinstance(payload, list):
|
||||
raise TickTickRequestError("TickTick get_projects returned an unexpected payload")
|
||||
return [_coerce_dataclass_payload(TickTickProject, project) for project in payload]
|
||||
|
||||
def get_tasks(self, project_id: str) -> list[TickTickTask]:
|
||||
self._require_token()
|
||||
if not project_id:
|
||||
raise ValueError("project_id must not be empty")
|
||||
payload = self._authorized_json_request(
|
||||
method="GET",
|
||||
path=f"/project/{parse.quote(project_id, safe='')}/data",
|
||||
operation="get_tasks",
|
||||
accepted_status_codes={200, 404},
|
||||
)
|
||||
if payload is None:
|
||||
return []
|
||||
if not isinstance(payload, dict):
|
||||
raise TickTickRequestError("TickTick get_tasks returned an unexpected payload")
|
||||
tasks = payload.get("tasks", [])
|
||||
if not isinstance(tasks, list):
|
||||
raise TickTickRequestError("TickTick get_tasks returned an invalid tasks payload")
|
||||
return [_coerce_dataclass_payload(TickTickTask, task) for task in tasks]
|
||||
|
||||
def has_duplicate_task(self, *, project_id: str, task_title: str) -> bool:
|
||||
if not task_title:
|
||||
raise ValueError("task_title must not be empty")
|
||||
return any(task.title == task_title for task in self.get_tasks(project_id))
|
||||
|
||||
def create_task(self, task: TickTickTask) -> None:
|
||||
self._require_token()
|
||||
if not task.projectId:
|
||||
raise ValueError("task.projectId must not be empty")
|
||||
if not task.title:
|
||||
raise ValueError("task.title must not be empty")
|
||||
if self.has_duplicate_task(project_id=task.projectId, task_title=task.title):
|
||||
return
|
||||
|
||||
payload = {key: value for key, value in asdict(task).items() if value is not None}
|
||||
self._authorized_json_request(
|
||||
method="POST",
|
||||
path="/task",
|
||||
operation="create_task",
|
||||
body=payload,
|
||||
accepted_status_codes={200},
|
||||
)
|
||||
|
||||
def _authorized_json_request(
|
||||
self,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
operation: str,
|
||||
body: Any | None = None,
|
||||
accepted_status_codes: set[int] | None = None,
|
||||
) -> Any:
|
||||
url = f"{TICKTICK_OPEN_API_BASE_URL}{path}"
|
||||
encoded_body = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = request.Request(url, data=encoded_body, method=method)
|
||||
req.add_header("Authorization", f"Bearer {self.settings.ticktick_token}")
|
||||
if body is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
return self._send_json_request(
|
||||
req,
|
||||
operation=operation,
|
||||
accepted_status_codes=accepted_status_codes,
|
||||
)
|
||||
|
||||
def _send_json_request(
|
||||
self,
|
||||
req: request.Request,
|
||||
*,
|
||||
operation: str,
|
||||
accepted_status_codes: set[int] | None = None,
|
||||
) -> Any:
|
||||
accepted_codes = accepted_status_codes or {200}
|
||||
try:
|
||||
with request.urlopen(req, timeout=self.timeout_seconds) as response:
|
||||
status_code = response.getcode()
|
||||
if status_code not in accepted_codes:
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} returned unexpected status {status_code}"
|
||||
)
|
||||
raw_body = response.read()
|
||||
except error.HTTPError as exc:
|
||||
if exc.code in accepted_codes:
|
||||
raw_body = exc.read()
|
||||
else:
|
||||
logger.warning(
|
||||
"TickTick %s failed with HTTP %s for %s",
|
||||
operation,
|
||||
exc.code,
|
||||
req.full_url,
|
||||
)
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} failed with HTTP {exc.code}"
|
||||
) from exc
|
||||
except error.URLError as exc:
|
||||
logger.warning("TickTick %s failed for %s: %s", operation, req.full_url, exc)
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} failed to reach TickTick API"
|
||||
) from exc
|
||||
|
||||
if not raw_body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw_body)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise TickTickRequestError(
|
||||
f"TickTick {operation} returned invalid JSON"
|
||||
) from exc
|
||||
|
||||
def _basic_auth_header(self) -> str:
|
||||
raw_credentials = f"{self._client_id()}:{self._client_secret()}"
|
||||
token = base64.b64encode(raw_credentials.encode("utf-8")).decode("ascii")
|
||||
return f"Basic {token}"
|
||||
|
||||
def _client_id(self) -> str:
|
||||
return self.settings.ticktick_client_id.strip()
|
||||
|
||||
def _client_secret(self) -> str:
|
||||
return self.settings.ticktick_client_secret.strip()
|
||||
|
||||
def _redirect_uri(self) -> str:
|
||||
return self.settings.ticktick_redirect_uri
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
+112
-8
@@ -1,26 +1,91 @@
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import models # noqa: F401
|
||||
from app.api.routes import pages, status
|
||||
from app.api.routes.api.config import router as api_config_router
|
||||
from app.api.routes.api.data import router as api_data_router
|
||||
from app.api.routes.api.session import router as api_session_router
|
||||
from app.api.routes import status
|
||||
from app.db import get_session_local
|
||||
from app.api.routes.homeassistant import router as homeassistant_router
|
||||
from app.api.routes.location import router as location_router
|
||||
from app.api.routes.poo import router as poo_router
|
||||
from app.api.routes.public_ip import router as public_ip_router
|
||||
from app.api.routes.ticktick import router as ticktick_router
|
||||
from app.config import get_settings
|
||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
||||
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
|
||||
from app.services.public_ip import check_public_ipv4_and_notify
|
||||
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _get_spa_dist_dir() -> Path:
|
||||
env_val = os.environ.get("SPA_DIST_DIR")
|
||||
if env_val:
|
||||
return Path(env_val)
|
||||
return _REPO_ROOT / "frontend" / "dist"
|
||||
|
||||
|
||||
def _run_scheduled_public_ip_check() -> None:
|
||||
session_local = get_session_local()
|
||||
session: Session = session_local()
|
||||
try:
|
||||
check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def ensure_auth_db_ready() -> None:
|
||||
session_local = get_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_runtime_dirs() -> None:
|
||||
settings = get_settings()
|
||||
for path in (settings.location_sqlite_path, settings.poo_sqlite_path):
|
||||
if path is not None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if settings.app_sqlite_path is not None:
|
||||
settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
ensure_runtime_dirs()
|
||||
ensure_auth_db_ready()
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
scheduler.add_job(
|
||||
_run_scheduled_public_ip_check,
|
||||
trigger=IntervalTrigger(hours=4),
|
||||
id="public-ip-check",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -31,8 +96,8 @@ def create_app() -> FastAPI:
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
description=(
|
||||
"Python rewrite skeleton for the home automation backend. "
|
||||
"This stage provides only the foundation for future module migration."
|
||||
"Home automation backend with auth, runtime config, Home Assistant "
|
||||
"integrations, TickTick integration, and SQLite-backed recorders."
|
||||
),
|
||||
)
|
||||
|
||||
@@ -40,8 +105,47 @@ def create_app() -> FastAPI:
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
app.include_router(status.router)
|
||||
app.include_router(pages.router)
|
||||
app.include_router(api_config_router)
|
||||
app.include_router(api_data_router)
|
||||
app.include_router(api_session_router)
|
||||
app.include_router(homeassistant_router)
|
||||
app.include_router(location_router)
|
||||
app.include_router(poo_router)
|
||||
app.include_router(public_ip_router)
|
||||
app.include_router(ticktick_router)
|
||||
|
||||
# SPA hosting: mount frontend/dist if it exists and has index.html.
|
||||
# If the SPA dist is absent (e.g. backend-only CI), skip SPA serving entirely
|
||||
# so that pytest stays green with only the API routes registered.
|
||||
spa_dist = _get_spa_dist_dir()
|
||||
spa_index = spa_dist / "index.html"
|
||||
if spa_dist.is_dir() and spa_index.is_file():
|
||||
spa_assets = spa_dist / "assets"
|
||||
if spa_assets.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=spa_assets), name="spa-assets")
|
||||
|
||||
# Resolve the dist root once so the containment check is fast and consistent.
|
||||
_spa_root = spa_dist.resolve()
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def spa_fallback(full_path: str, request: Request) -> FileResponse: # noqa: RUF029
|
||||
# Explicit 404 for unmatched /api/* — never return index.html for API paths.
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="not found")
|
||||
# Resolve candidate to an absolute path and verify it stays within the SPA
|
||||
# dist root. Without this check, URL-encoded ".." sequences (e.g. "..%2f")
|
||||
# bypass Starlette's path parameter handling and allow arbitrary file reads.
|
||||
candidate = (spa_dist / full_path).resolve()
|
||||
if candidate.is_file() and candidate.is_relative_to(_spa_root):
|
||||
return FileResponse(candidate)
|
||||
# For any path outside the dist root, or for SPA client routes that don't
|
||||
# correspond to a real file, return index.html so the SPA router handles it.
|
||||
return FileResponse(spa_index)
|
||||
else:
|
||||
logger.warning(
|
||||
"SPA dist not found at %s — SPA hosting disabled (API-only mode).", spa_dist
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
+13
-1
@@ -1,5 +1,17 @@
|
||||
"""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.poo import PooRecord
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
|
||||
__all__ = ["Location"]
|
||||
__all__ = [
|
||||
"AppConfigEntry",
|
||||
"AuthSession",
|
||||
"AuthUser",
|
||||
"Location",
|
||||
"PooRecord",
|
||||
"PublicIPHistory",
|
||||
"PublicIPState",
|
||||
]
|
||||
|
||||
@@ -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.db import Base
|
||||
|
||||
|
||||
class AuthUser(Base):
|
||||
__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(Base):
|
||||
__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")
|
||||
@@ -1,4 +0,0 @@
|
||||
from app.db import Base
|
||||
|
||||
__all__ = ["Base"]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AppConfigEntry(Base):
|
||||
__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)
|
||||
@@ -0,0 +1,13 @@
|
||||
from sqlalchemy import Float, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PooRecord(Base):
|
||||
__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)
|
||||
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class PublicIPState(Base):
|
||||
__tablename__ = "public_ip_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
current_ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
previous_ipv4: Mapped[str | None] = mapped_column(String(45), nullable=True)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
last_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_check_status: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
last_check_error: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
last_provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class PublicIPHistory(Base):
|
||||
__tablename__ = "public_ip_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ipv4: Mapped[str] = mapped_column(String(45), nullable=False)
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
change_type: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ConfigField(BaseModel):
|
||||
env_name: str
|
||||
label: str
|
||||
value: str
|
||||
secret: bool
|
||||
input_type: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class ConfigSection(BaseModel):
|
||||
name: str
|
||||
fields: list[ConfigField]
|
||||
|
||||
|
||||
class ConfigResponse(BaseModel):
|
||||
sections: list[ConfigSection]
|
||||
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
"""Flat mapping of env_name → value, mirroring the existing form semantics."""
|
||||
|
||||
updates: dict[str, str]
|
||||
|
||||
|
||||
class ConfigUpdateResponse(BaseModel):
|
||||
sections: list[ConfigSection]
|
||||
|
||||
|
||||
class SmtpTestResponse(BaseModel):
|
||||
"""Response from POST /api/config/smtp/test."""
|
||||
|
||||
result: Literal["success", "config-error", "failed"]
|
||||
message: str
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocationRecord(BaseModel):
|
||||
person: str
|
||||
datetime: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: float | None
|
||||
|
||||
|
||||
class LocationsResponse(BaseModel):
|
||||
items: list[LocationRecord]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class LocationUpdateRequest(BaseModel):
|
||||
"""PATCH body for a location record — all fields optional; PK fields excluded."""
|
||||
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
altitude: float | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Poo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PooRecord(BaseModel):
|
||||
timestamp: str
|
||||
status: str
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
class PooResponse(BaseModel):
|
||||
items: list[PooRecord]
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class PooUpdateRequest(BaseModel):
|
||||
"""PATCH body for a poo record — all fields optional; PK field excluded."""
|
||||
|
||||
status: str | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public IP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PublicIPStateSchema(BaseModel):
|
||||
id: int
|
||||
current_ipv4: str
|
||||
previous_ipv4: str | None
|
||||
first_seen_at: datetime
|
||||
last_checked_at: datetime
|
||||
last_changed_at: datetime | None
|
||||
last_check_status: str
|
||||
last_check_error: str | None
|
||||
last_provider: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PublicIPHistorySchema(BaseModel):
|
||||
id: int
|
||||
ipv4: str
|
||||
observed_at: datetime
|
||||
change_type: str
|
||||
provider: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PublicIPResponse(BaseModel):
|
||||
state: PublicIPStateSchema | None
|
||||
history: list[PublicIPHistorySchema]
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class HomeAssistantPublishEnvelope(BaseModel):
|
||||
target: str
|
||||
action: str
|
||||
content: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -5,7 +5,6 @@ class LocationRecordRequest(BaseModel):
|
||||
person: str
|
||||
latitude: str
|
||||
longitude: str
|
||||
altitude: str = ""
|
||||
altitude: str | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class PooRecordRequest(BaseModel):
|
||||
status: str
|
||||
latitude: str
|
||||
longitude: str
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -0,0 +1,13 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
PublicIPCheckStatus = Literal["first_seen", "unchanged", "changed", "error"]
|
||||
|
||||
|
||||
class PublicIPCheckResponse(BaseModel):
|
||||
status: PublicIPCheckStatus
|
||||
checked_at: datetime
|
||||
changed: bool
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SessionUser(BaseModel):
|
||||
username: str
|
||||
force_password_change: bool
|
||||
|
||||
|
||||
class SessionResponse(BaseModel):
|
||||
user: SessionUser
|
||||
csrf_token: str
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
confirm_password: str
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,287 @@
|
||||
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.db import reset_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("SMTP", "SMTP_ENABLED", "smtp_enabled", "SMTP Enabled"),
|
||||
ConfigField("SMTP", "SMTP_HOST", "smtp_host", "SMTP Host"),
|
||||
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
|
||||
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
|
||||
ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True),
|
||||
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
|
||||
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
|
||||
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
|
||||
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"),
|
||||
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_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_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_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,
|
||||
"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,
|
||||
"smtp_enabled": settings.smtp_enabled,
|
||||
"smtp_host": settings.smtp_host,
|
||||
"smtp_port": settings.smtp_port,
|
||||
"smtp_username": settings.smtp_username,
|
||||
"smtp_password": settings.smtp_password,
|
||||
"smtp_from_name": settings.smtp_from_name,
|
||||
"smtp_from_address": settings.smtp_from_address,
|
||||
"smtp_to_address": settings.smtp_to_address,
|
||||
"smtp_use_starttls": settings.smtp_use_starttls,
|
||||
"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,
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
import smtplib
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class EmailConfigurationError(ValueError):
|
||||
"""Raised when SMTP settings are incomplete or disabled."""
|
||||
|
||||
|
||||
class EmailDeliveryError(RuntimeError):
|
||||
"""Raised when sending email fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SMTPConfig:
|
||||
host: str
|
||||
port: int
|
||||
username: str
|
||||
password: str
|
||||
from_name: str
|
||||
from_address: str
|
||||
to_address: str
|
||||
use_starttls: bool
|
||||
|
||||
|
||||
def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTPConfig:
|
||||
if require_enabled and not settings.smtp_enabled:
|
||||
raise EmailConfigurationError("SMTP is disabled")
|
||||
|
||||
if not settings.smtp_host:
|
||||
raise EmailConfigurationError("SMTP host is required")
|
||||
|
||||
if settings.smtp_port <= 0:
|
||||
raise EmailConfigurationError("SMTP port must be greater than zero")
|
||||
|
||||
if not settings.smtp_from_address:
|
||||
raise EmailConfigurationError("SMTP from address is required")
|
||||
|
||||
if not settings.smtp_to_address:
|
||||
raise EmailConfigurationError("SMTP to address is required")
|
||||
|
||||
return SMTPConfig(
|
||||
host=settings.smtp_host,
|
||||
port=settings.smtp_port,
|
||||
username=settings.smtp_username,
|
||||
password=settings.smtp_password,
|
||||
from_name=settings.smtp_from_name,
|
||||
from_address=settings.smtp_from_address,
|
||||
to_address=settings.smtp_to_address,
|
||||
use_starttls=settings.smtp_use_starttls,
|
||||
)
|
||||
|
||||
|
||||
def is_smtp_ready(settings: Settings) -> bool:
|
||||
try:
|
||||
get_smtp_config(settings, require_enabled=False)
|
||||
except EmailConfigurationError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def send_plaintext_email(
|
||||
settings: Settings,
|
||||
*,
|
||||
subject: str,
|
||||
body: str,
|
||||
recipient: str | None = None,
|
||||
require_enabled: bool = True,
|
||||
) -> None:
|
||||
smtp_config = get_smtp_config(settings, require_enabled=require_enabled)
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = _build_from_header(smtp_config)
|
||||
message["To"] = recipient or smtp_config.to_address
|
||||
message.set_content(body)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(smtp_config.host, smtp_config.port, timeout=10) as smtp:
|
||||
smtp.ehlo()
|
||||
if smtp_config.use_starttls:
|
||||
smtp.starttls()
|
||||
smtp.ehlo()
|
||||
if smtp_config.username:
|
||||
smtp.login(smtp_config.username, smtp_config.password)
|
||||
smtp.send_message(
|
||||
message,
|
||||
from_addr=smtp_config.from_address,
|
||||
to_addrs=[recipient or smtp_config.to_address],
|
||||
)
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
error_message = _sanitize_error_message(str(exc), smtp_config.password)
|
||||
raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc
|
||||
|
||||
|
||||
def send_smtp_test_email(settings: Settings) -> None:
|
||||
send_plaintext_email(
|
||||
settings,
|
||||
subject="Home Automation SMTP Test",
|
||||
body="This is a test email from Home Automation SMTP settings.",
|
||||
require_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def send_public_ip_changed_email(
|
||||
settings: Settings,
|
||||
*,
|
||||
previous_ipv4: str,
|
||||
current_ipv4: str,
|
||||
detected_at: datetime,
|
||||
) -> None:
|
||||
send_plaintext_email(
|
||||
settings,
|
||||
subject="Public IP changed",
|
||||
body=(
|
||||
"Your public IPv4 address has changed.\n\n"
|
||||
f"Previous IP: {previous_ipv4}\n"
|
||||
f"Current IP: {current_ipv4}\n"
|
||||
f"Detected at: {_format_utc_timestamp(detected_at)}\n\n"
|
||||
"If you use Namecheap API trusted IP restrictions, you may need to "
|
||||
"update the trusted IP manually.\n"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_error_message(message: str, password: str) -> str:
|
||||
sanitized = message
|
||||
if password:
|
||||
sanitized = sanitized.replace(password, "[redacted]")
|
||||
return sanitized
|
||||
|
||||
|
||||
def _format_utc_timestamp(value: datetime) -> str:
|
||||
if value.tzinfo is None:
|
||||
normalized = value.replace(tzinfo=UTC)
|
||||
else:
|
||||
normalized = value.astimezone(UTC)
|
||||
return normalized.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
|
||||
|
||||
def _build_from_header(smtp_config: SMTPConfig) -> str:
|
||||
if smtp_config.from_name:
|
||||
return formataddr((smtp_config.from_name, smtp_config.from_address))
|
||||
return smtp_config.from_address
|
||||
@@ -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)
|
||||
@@ -1,19 +1,26 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import insert
|
||||
from sqlalchemy import delete, insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.location import Location
|
||||
from app.schemas.location import LocationRecordRequest
|
||||
|
||||
|
||||
def _parse_float_compat(value: str) -> float:
|
||||
def _parse_optional_float_compat(value: str | None) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _parse_required_float(value: str, field_name: str) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"Invalid numeric value for {field_name}") from exc
|
||||
|
||||
|
||||
def _utc_now_rfc3339() -> str:
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
return now.isoformat().replace("+00:00", "Z")
|
||||
@@ -26,11 +33,65 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
|
||||
.values(
|
||||
person=payload.person,
|
||||
datetime=_utc_now_rfc3339(),
|
||||
latitude=_parse_float_compat(payload.latitude),
|
||||
longitude=_parse_float_compat(payload.longitude),
|
||||
altitude=_parse_float_compat(payload.altitude),
|
||||
latitude=_parse_required_float(payload.latitude, "latitude"),
|
||||
longitude=_parse_required_float(payload.longitude, "longitude"),
|
||||
altitude=_parse_optional_float_compat(payload.altitude),
|
||||
)
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
|
||||
|
||||
def update_location(
|
||||
session: Session,
|
||||
person: str,
|
||||
datetime_pk: str,
|
||||
*,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
altitude: float | None,
|
||||
) -> Location | None:
|
||||
"""Update non-PK fields of a single location row.
|
||||
|
||||
Returns the updated ORM object, or ``None`` if the PK does not exist.
|
||||
The caller must not pass PK fields — they are immutable.
|
||||
Only fields with a non-``None`` value are written; ``altitude`` being
|
||||
``None`` in the request means "leave unchanged", not "clear to NULL".
|
||||
"""
|
||||
row = session.execute(
|
||||
select(Location).where(
|
||||
Location.person == person,
|
||||
Location.datetime == datetime_pk,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
if latitude is not None:
|
||||
row.latitude = latitude
|
||||
if longitude is not None:
|
||||
row.longitude = longitude
|
||||
if altitude is not None:
|
||||
row.altitude = altitude
|
||||
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def delete_location(session: Session, person: str, datetime_pk: str) -> bool:
|
||||
"""Delete the single location row identified by its full composite PK.
|
||||
|
||||
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
|
||||
not exist (caller should raise 404). The DELETE is scoped to the exact PK
|
||||
— no batch/truncate path exists.
|
||||
"""
|
||||
result = session.execute(
|
||||
delete(Location).where(
|
||||
Location.person == person,
|
||||
Location.datetime == datetime_pk,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
|
||||
from sqlalchemy import delete, 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 update_poo_record(
|
||||
session: Session,
|
||||
timestamp_pk: str,
|
||||
*,
|
||||
status: str | None,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
) -> PooRecord | None:
|
||||
"""Update non-PK fields of a single poo record row.
|
||||
|
||||
Returns the updated ORM object, or ``None`` if the PK does not exist.
|
||||
The ``timestamp`` PK is immutable and must not be passed as an update field.
|
||||
Only fields with a non-``None`` value are written.
|
||||
"""
|
||||
row = session.execute(
|
||||
select(PooRecord).where(PooRecord.timestamp == timestamp_pk)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
if status is not None:
|
||||
row.status = status
|
||||
if latitude is not None:
|
||||
row.latitude = latitude
|
||||
if longitude is not None:
|
||||
row.longitude = longitude
|
||||
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def delete_poo_record(session: Session, timestamp_pk: str) -> bool:
|
||||
"""Delete the single poo record row identified by its PK.
|
||||
|
||||
Returns ``True`` if exactly one row was deleted, ``False`` if the PK did
|
||||
not exist (caller should raise 404). The DELETE is scoped to the exact PK
|
||||
— no batch/truncate path exists.
|
||||
"""
|
||||
result = session.execute(
|
||||
delete(PooRecord).where(PooRecord.timestamp == timestamp_pk)
|
||||
)
|
||||
session.commit()
|
||||
return result.rowcount == 1
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Callable, Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||
from app.services.config_page import build_runtime_settings
|
||||
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_public_ip_changed_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PUBLIC_IP_PROVIDER_NAME = "ipify"
|
||||
PUBLIC_IP_PROVIDER_URL = "https://api.ipify.org"
|
||||
PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
PublicIPResultStatus = Literal["first_seen", "unchanged", "changed", "error"]
|
||||
PublicIPv4Fetcher = Callable[[], str]
|
||||
|
||||
|
||||
class PublicIPCheckError(RuntimeError):
|
||||
"""Raised when the public IPv4 provider cannot return a valid IPv4."""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PublicIPCheckResult:
|
||||
status: PublicIPResultStatus
|
||||
checked_at: datetime
|
||||
changed: bool
|
||||
previous_ipv4: str | None = None
|
||||
current_ipv4: str | None = None
|
||||
|
||||
|
||||
def check_public_ipv4(
|
||||
session: Session,
|
||||
*,
|
||||
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
|
||||
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
|
||||
) -> PublicIPCheckResult:
|
||||
checked_at = _utc_now()
|
||||
state = session.scalar(select(PublicIPState).where(PublicIPState.id == 1).limit(1))
|
||||
|
||||
try:
|
||||
raw_ipv4 = (fetch_public_ipv4 or fetch_public_ipv4_from_provider)()
|
||||
current_ipv4 = _validate_ipv4(raw_ipv4)
|
||||
except PublicIPCheckError as exc:
|
||||
logger.warning("Public IPv4 check failed: %s", exc)
|
||||
if state is not None:
|
||||
state.last_checked_at = checked_at
|
||||
state.last_check_status = "error"
|
||||
state.last_check_error = str(exc)
|
||||
state.last_provider = provider_name
|
||||
session.commit()
|
||||
return PublicIPCheckResult(status="error", checked_at=checked_at, changed=False)
|
||||
|
||||
if state is None:
|
||||
state = PublicIPState(
|
||||
id=1,
|
||||
current_ipv4=current_ipv4,
|
||||
previous_ipv4=None,
|
||||
first_seen_at=checked_at,
|
||||
last_checked_at=checked_at,
|
||||
last_changed_at=None,
|
||||
last_check_status="first_seen",
|
||||
last_check_error=None,
|
||||
last_provider=provider_name,
|
||||
)
|
||||
session.add(state)
|
||||
session.add(
|
||||
PublicIPHistory(
|
||||
ipv4=current_ipv4,
|
||||
observed_at=checked_at,
|
||||
change_type="first_seen",
|
||||
provider=provider_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return PublicIPCheckResult(
|
||||
status="first_seen",
|
||||
checked_at=checked_at,
|
||||
changed=False,
|
||||
current_ipv4=current_ipv4,
|
||||
)
|
||||
|
||||
if state.current_ipv4 == current_ipv4:
|
||||
state.last_checked_at = checked_at
|
||||
state.last_check_status = "unchanged"
|
||||
state.last_check_error = None
|
||||
state.last_provider = provider_name
|
||||
session.commit()
|
||||
return PublicIPCheckResult(
|
||||
status="unchanged",
|
||||
checked_at=checked_at,
|
||||
changed=False,
|
||||
current_ipv4=current_ipv4,
|
||||
)
|
||||
|
||||
previous_ipv4 = state.current_ipv4
|
||||
state.previous_ipv4 = previous_ipv4
|
||||
state.current_ipv4 = current_ipv4
|
||||
state.last_checked_at = checked_at
|
||||
state.last_changed_at = checked_at
|
||||
state.last_check_status = "changed"
|
||||
state.last_check_error = None
|
||||
state.last_provider = provider_name
|
||||
session.add(
|
||||
PublicIPHistory(
|
||||
ipv4=current_ipv4,
|
||||
observed_at=checked_at,
|
||||
change_type="changed",
|
||||
provider=provider_name,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return PublicIPCheckResult(
|
||||
status="changed",
|
||||
checked_at=checked_at,
|
||||
changed=True,
|
||||
previous_ipv4=previous_ipv4,
|
||||
current_ipv4=current_ipv4,
|
||||
)
|
||||
|
||||
|
||||
def check_public_ipv4_and_notify(
|
||||
session: Session,
|
||||
*,
|
||||
bootstrap_settings: Settings,
|
||||
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
|
||||
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
|
||||
) -> PublicIPCheckResult:
|
||||
result = check_public_ipv4(
|
||||
session,
|
||||
fetch_public_ipv4=fetch_public_ipv4,
|
||||
provider_name=provider_name,
|
||||
)
|
||||
|
||||
if result.status != "changed" or result.previous_ipv4 is None or result.current_ipv4 is None:
|
||||
return result
|
||||
|
||||
runtime_settings = build_runtime_settings(session, bootstrap_settings)
|
||||
try:
|
||||
send_public_ip_changed_email(
|
||||
runtime_settings,
|
||||
previous_ipv4=result.previous_ipv4,
|
||||
current_ipv4=result.current_ipv4,
|
||||
detected_at=result.checked_at,
|
||||
)
|
||||
except (EmailConfigurationError, EmailDeliveryError) as exc:
|
||||
logger.warning("Public IPv4 change notification failed: %s", exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def fetch_public_ipv4_from_provider() -> str:
|
||||
try:
|
||||
response = httpx.get(
|
||||
PUBLIC_IP_PROVIDER_URL,
|
||||
params={"format": "text"},
|
||||
timeout=PUBLIC_IP_PROVIDER_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
raise PublicIPCheckError(f"provider request failed: {exc}") from exc
|
||||
|
||||
return response.text.strip()
|
||||
|
||||
|
||||
def _validate_ipv4(raw_value: str) -> str:
|
||||
if not raw_value:
|
||||
raise PublicIPCheckError("provider returned an empty response")
|
||||
|
||||
try:
|
||||
parsed = ipaddress.ip_address(raw_value)
|
||||
except ValueError as exc:
|
||||
raise PublicIPCheckError("provider returned an invalid IPv4 value") from exc
|
||||
|
||||
if parsed.version != 4:
|
||||
raise PublicIPCheckError("provider returned a non-IPv4 value")
|
||||
|
||||
return str(parsed)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
+151
-1
@@ -61,6 +61,11 @@ h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.single-column {
|
||||
grid-template-columns: minmax(180px, 320px);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.meta div {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
@@ -83,6 +88,147 @@ a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
max-width: 520px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.auth-form,
|
||||
.logout-form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(31, 41, 51, 0.14);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
width: fit-content;
|
||||
min-width: 120px;
|
||||
padding: 12px 18px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: rgba(157, 37, 37, 0.08);
|
||||
border: 1px solid rgba(157, 37, 37, 0.14);
|
||||
color: #8b2a2a;
|
||||
}
|
||||
|
||||
.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) {
|
||||
.shell {
|
||||
margin: 24px auto;
|
||||
@@ -91,5 +237,9 @@ a {
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.integration-action-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<p class="eyebrow">Python Rewrite Skeleton</p>
|
||||
<h1>{{ app_name }}</h1>
|
||||
<p class="lead">
|
||||
这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、
|
||||
测试、模板和容器化基础,不包含业务逻辑迁移。
|
||||
</p>
|
||||
<dl class="meta">
|
||||
<div>
|
||||
<dt>运行环境</dt>
|
||||
<dd>{{ app_env }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>健康检查</dt>
|
||||
<dd><a href="/status">/status</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>OpenAPI</dt>
|
||||
<dd><a href="/docs">/docs</a></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Notion</dt>
|
||||
<dd>{{ notion_status }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
+16
-6
@@ -13,12 +13,20 @@ anyio==4.13.0
|
||||
# httpx
|
||||
# starlette
|
||||
# watchfiles
|
||||
apscheduler==3.11.2
|
||||
# via -r requirements.in
|
||||
argon2-cffi==25.1.0
|
||||
# via -r requirements.in
|
||||
argon2-cffi-bindings==25.1.0
|
||||
# via argon2-cffi
|
||||
build==1.4.3
|
||||
# via pip-tools
|
||||
certifi==2026.2.25
|
||||
# via
|
||||
# httpcore
|
||||
# httpx
|
||||
cffi==2.0.0
|
||||
# via argon2-cffi-bindings
|
||||
click==8.3.2
|
||||
# via
|
||||
# pip-tools
|
||||
@@ -36,21 +44,19 @@ httpcore==1.0.9
|
||||
httptools==0.7.1
|
||||
# via uvicorn
|
||||
httpx==0.28.1
|
||||
# via -r dev-requirements.in
|
||||
# via
|
||||
# -r dev-requirements.in
|
||||
# -r requirements.in
|
||||
idna==3.11
|
||||
# via
|
||||
# anyio
|
||||
# httpx
|
||||
iniconfig==2.3.0
|
||||
# via pytest
|
||||
jinja2==3.1.6
|
||||
# via -r requirements.in
|
||||
mako==1.3.11
|
||||
# via alembic
|
||||
markupsafe==3.0.3
|
||||
# via
|
||||
# jinja2
|
||||
# mako
|
||||
# via mako
|
||||
packaging==26.1
|
||||
# via
|
||||
# build
|
||||
@@ -60,6 +66,8 @@ pip-tools==7.5.3
|
||||
# via -r dev-requirements.in
|
||||
pluggy==1.6.0
|
||||
# via pytest
|
||||
pycparser==2.23
|
||||
# via cffi
|
||||
pydantic==2.13.2
|
||||
# via
|
||||
# fastapi
|
||||
@@ -104,6 +112,8 @@ typing-inspection==0.4.2
|
||||
# via
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
tzlocal==5.3.1
|
||||
# via apscheduler
|
||||
uvicorn[standard]==0.44.0
|
||||
# via -r requirements.in
|
||||
uvloop==0.22.1
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Local dev override — use explicitly:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||
# Isolated from the production stack so both can run on this host at once:
|
||||
# - distinct compose project name (separate network/grouping)
|
||||
# - distinct container names (-dev suffix; Docker rejects duplicate names)
|
||||
# - distinct image tag (local build doesn't clobber the prod :latest tag)
|
||||
name: home-automation-dev
|
||||
|
||||
services:
|
||||
migration:
|
||||
build: .
|
||||
image: home-automation:dev
|
||||
container_name: home-automation-migration-dev
|
||||
environment:
|
||||
# In-container path for the mounted ./data volume (./data -> /app/data).
|
||||
# Overrides the host-absolute APP_DATABASE_URL in .env for local compose runs.
|
||||
APP_DATABASE_URL: "sqlite:////app/data/app.db"
|
||||
|
||||
app:
|
||||
build: .
|
||||
image: home-automation:dev
|
||||
container_name: home-automation-app-dev
|
||||
# Publish on 8001 for dev. `!override` REPLACES the base ports list instead of
|
||||
# appending to it, so the dev stack does NOT also bind the production 8881.
|
||||
ports: !override
|
||||
- "127.0.0.1:8001:8000"
|
||||
environment:
|
||||
APP_DATABASE_URL: "sqlite:////app/data/app.db"
|
||||
+24
-13
@@ -1,16 +1,27 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
LOCATION_DATABASE_URL: sqlite:////app/data/locationRecorder.db
|
||||
POO_DATABASE_URL: sqlite:////app/data/pooRecorder.db
|
||||
APP_HOST: 0.0.0.0
|
||||
APP_PORT: 8000
|
||||
migration:
|
||||
container_name: home-automation-migration
|
||||
image: code.wanderingbadger.dev/tliu93/home-automation:latest
|
||||
user: "1000:1000"
|
||||
restart: "no"
|
||||
init: true
|
||||
command: ["python", "-m", "scripts.run_migrations"]
|
||||
volumes:
|
||||
- ./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
|
||||
|
||||
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
exec "$@"
|
||||
@@ -24,27 +24,32 @@
|
||||
- `config.py`
|
||||
- 环境变量驱动的 settings
|
||||
- `db.py`
|
||||
- SQLAlchemy engine / session / Base
|
||||
- 统一数据层:一个 `Base`、一个绑定 `app_database_url` 的 cached engine(SQLite WAL)、`get_engine` / `get_session_local` / `reset_db_caches` / `get_db_session`
|
||||
- `dependencies.py`
|
||||
- 通用依赖注入
|
||||
- `api/`
|
||||
- HTTP routes
|
||||
- `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD
|
||||
- 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等
|
||||
- `models/`
|
||||
- SQLAlchemy models
|
||||
- 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db` 中
|
||||
- `schemas/`
|
||||
- Pydantic schemas
|
||||
- `services/`
|
||||
- 业务服务层
|
||||
- 当前已迁入 config page 的 DB 持久化逻辑
|
||||
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
|
||||
- 当前已迁入 SMTP 发信与测试发信逻辑
|
||||
- `integrations/`
|
||||
- 外部系统适配层占位
|
||||
- `templates/`
|
||||
- Jinja2 模板
|
||||
- 外部系统适配层
|
||||
- 当前已迁入 Home Assistant outbound adapter
|
||||
- `static/`
|
||||
- 极简静态资源
|
||||
|
||||
### `alembic/`
|
||||
### `alembic_app/`
|
||||
|
||||
数据库 migration 基础设施。当前尚未迁入业务表,但迁移链路已就绪。
|
||||
App DB 的唯一 Alembic migration 链,同时管理 `location` / `poo_records` 表。M1 将三个独立 DB 合并进 `app.db` 后,`alembic_location/` 与 `alembic_poo/` 已退役,全部由此链统一管理。
|
||||
|
||||
### `tests/`
|
||||
|
||||
@@ -54,16 +59,28 @@ pytest 测试目录。后续可以在这里自然扩展:
|
||||
- mock tests
|
||||
- integration tests
|
||||
|
||||
### `frontend/`
|
||||
|
||||
React SPA 前端(M2 引入)。Vite + React + TypeScript + Mantine,由 FastAPI 同源托管。
|
||||
|
||||
- `src/`:React 源码
|
||||
- `src/api/`:由 `openapi/openapi.json` 生成的类型化 client(`schema.d.ts`)+ fetch 封装
|
||||
- `dist/`:`npm run build` 产物,由 FastAPI 的 `SPA_DIST_DIR` 挂载并对非 `/api` 路径做 fallback
|
||||
|
||||
### `scripts/`
|
||||
|
||||
辅助脚本目录。当前包含 OpenAPI 导出脚本。
|
||||
辅助脚本目录。当前包含 OpenAPI 导出脚本(`export_openapi.py`)与数据层辅助脚本。
|
||||
|
||||
### `openapi/`
|
||||
|
||||
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
|
||||
|
||||
## 当前约束
|
||||
|
||||
- 当前只搭骨架,不迁业务逻辑
|
||||
- 当前数据库继续使用 SQLite
|
||||
- 当前不引入前后端分离
|
||||
- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
|
||||
- 当前不设计 Notion 模块
|
||||
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
||||
|
||||
## 关于 Notion
|
||||
|
||||
@@ -76,4 +93,3 @@ Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确
|
||||
- 不预留 Notion 相关业务流
|
||||
|
||||
如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。
|
||||
|
||||
|
||||
+120
@@ -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
|
||||
@@ -1,557 +0,0 @@
|
||||
# 当前系统盘点
|
||||
|
||||
本文档用于盘点当前 branch 上的 Go 实现,并将其作为后续 Python 重构的唯一事实基线。
|
||||
|
||||
## 范围与基线
|
||||
|
||||
- 当前事实基线:`legacy/go-backend/src/` 下的 Go 代码
|
||||
- 不纳入当前基线:更早的 Python 版本
|
||||
- 主入口:[`legacy/go-backend/src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62)
|
||||
|
||||
## 系统概览
|
||||
|
||||
当前应用是一个单进程 Go HTTP 服务,具备以下特征:
|
||||
|
||||
- 暴露少量 REST API
|
||||
- 使用本地 SQLite 做持久化
|
||||
- 调用 Home Assistant API 和 webhook
|
||||
- 通过 OAuth 和 REST API 集成 TickTick
|
||||
- 当前仍依赖 Notion 做 poo 记录同步
|
||||
- 内置一个每日执行的定时同步任务
|
||||
|
||||
进程启动后会先读取 YAML 配置文件,再初始化 Notion 与 TickTick 工具层、初始化各业务组件自己的 SQLite 数据库、注册路由、启动调度器,最后在配置的端口上提供 HTTP 服务。可参考 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) 和 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:104)。
|
||||
|
||||
## API 盘点
|
||||
|
||||
### `GET /status`
|
||||
|
||||
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:106)
|
||||
- 用途:基础存活检查
|
||||
- 请求参数:无
|
||||
- 请求体:无
|
||||
- 响应:纯文本 `OK`
|
||||
- 鉴权:当前代码中无鉴权
|
||||
- 调用方类型:通用健康检查,可能用于本地监控或 supervisor 级别的探活
|
||||
|
||||
### `GET /poo/latest`
|
||||
|
||||
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:110)
|
||||
- 处理函数:[`pooRecorder.HandleNotifyLatestPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:87)
|
||||
- 用途:将最新一条 poo 状态重新发布到 Home Assistant 的 sensor state
|
||||
- 请求参数:无
|
||||
- 请求体:无
|
||||
- 响应:
|
||||
- 成功:空响应体,默认 HTTP 200
|
||||
- 失败:通过 `http.Error(...)` 返回文本错误信息
|
||||
- 鉴权:当前代码中无鉴权
|
||||
- 外部调用方:
|
||||
- 会被 `POST /homeassistant/publish` 间接触发,当 `target=poo_recorder` 且 `action=get_latest` 时,代码会通过内部 HTTP 请求访问 `http://localhost:{port}/poo/latest`,见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:110)
|
||||
- 副作用:
|
||||
- 从 `poo_records` 读取最新一条记录
|
||||
- 调用 Home Assistant `/api/states/{entity_id}` 更新 sensor 状态,见 [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:65)
|
||||
|
||||
### `POST /poo/record`
|
||||
|
||||
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:111)
|
||||
- 处理函数:[`pooRecorder.HandleRecordPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57)
|
||||
- 用途:记录一条 poo 事件,同时镜像到 Notion、刷新 Home Assistant sensor,并可选触发一个 Home Assistant webhook
|
||||
- 请求体 JSON:
|
||||
- `status: string`
|
||||
- `latitude: string`
|
||||
- `longitude: string`
|
||||
- 请求校验:
|
||||
- JSON decoder 开启了 `DisallowUnknownFields`
|
||||
- 如果配置里缺少 `pooRecorder.tableId`,请求会直接失败,虽然从纯本地 DB 角度看本来仍有可能写入成功
|
||||
- 响应:
|
||||
- 成功:空响应体,默认 HTTP 200
|
||||
- 请求错误:返回 decoder 错误文本,HTTP 400
|
||||
- 服务端错误:返回错误文本,HTTP 500
|
||||
- 鉴权:当前代码中无鉴权
|
||||
- 外部调用方:大概率是 Home Assistant、移动端 shortcut、或手工调用;代码中没有明确写死调用方
|
||||
- 副作用:
|
||||
- 向 SQLite `poo_records` 插入一条记录
|
||||
- 异步向 Notion 追加一行
|
||||
- 同步发布最新 Home Assistant sensor 状态
|
||||
- 如果存在 `pooRecorder.webhookId`,异步触发一个 Home Assistant webhook
|
||||
|
||||
### `POST /homeassistant/publish`
|
||||
|
||||
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:112)
|
||||
- 处理函数:[`HomeAssistant.HandleHaMessage`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36)
|
||||
- 用途:接收自动化消息,根据 `target` 和 `action` 做分发
|
||||
- 请求体 JSON:
|
||||
- `target: string`
|
||||
- `action: string`
|
||||
- `content: string`
|
||||
- 请求校验:
|
||||
- JSON decoder 开启了 `DisallowUnknownFields`
|
||||
- 响应:
|
||||
- 成功路径:空响应体,默认 HTTP 200
|
||||
- 失败路径:通常为空响应体并返回 HTTP 500;TickTick auth 回调是单独的接口,不在这里
|
||||
- 鉴权:当前代码中无鉴权
|
||||
- 外部调用方:设计意图上是给 Home Assistant automation 消息调用
|
||||
|
||||
当前代码支持的消息契约如下:
|
||||
|
||||
- `target=poo_recorder`, `action=get_latest`
|
||||
- 转发到本地 `GET /poo/latest`
|
||||
- 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:72)
|
||||
|
||||
- `target=location_recorder`, `action=record`
|
||||
- `content` 预期是一个 JSON 风格字符串,实际很可能使用单引号
|
||||
- 当前代码会用 `strings.ReplaceAll(message.Content, "'", "\"")` 做归一化
|
||||
- 然后转发到本地 `POST /location/record`
|
||||
- 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82)
|
||||
|
||||
- `target=ticktick`, `action=create_action_task`
|
||||
- `content` 预期可解析为:
|
||||
- `action: string`
|
||||
- `due_hour: int`
|
||||
- 当前代码会忽略调用方传来的 `title` 字段,而是把 `action` 映射为 TickTick task title
|
||||
- 到期时间的计算方式是:取 `now + due_hour` 后所在日期的“次日零点”,再转成 TickTick 使用的时间格式
|
||||
- 最终在配置指定的 TickTick project 中创建任务
|
||||
- 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:124)
|
||||
|
||||
不支持的 `target` 或 `action` 会返回 HTTP 500,并打 warning 日志。相关测试在 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:68)。
|
||||
|
||||
### `POST /location/record`
|
||||
|
||||
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:114)
|
||||
- 处理函数:[`locationRecorder.HandleRecordLocation`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:43)
|
||||
- 用途:记录人的位置点,用于人生轨迹 / movement history
|
||||
- 请求体 JSON:
|
||||
- `person: string`
|
||||
- `latitude: string`
|
||||
- `longitude: string`
|
||||
- `altitude: string`,从请求结构上看是可选,但代码里即使为空也会被解析成 `0`
|
||||
- 请求校验:
|
||||
- JSON decoder 开启了 `DisallowUnknownFields`
|
||||
- 数值解析错误会被忽略;如果 `latitude` / `longitude` / `altitude` 不是合法数字,当前实现会静默落成 `0`
|
||||
- 响应:
|
||||
- 成功:空响应体,默认 HTTP 200
|
||||
- 请求错误:返回 decoder 错误文本,HTTP 400
|
||||
- 鉴权:当前代码中无鉴权
|
||||
- 外部调用方:
|
||||
- 可被任意客户端直接调用
|
||||
- 也会被 `POST /homeassistant/publish` 间接触发
|
||||
- 副作用:
|
||||
- 向 SQLite `location` 表插入一条记录
|
||||
|
||||
### `GET /ticktick/auth/code`
|
||||
|
||||
- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:116)
|
||||
- 处理函数:[`TicktickUtilImpl.HandleAuthCode`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103)
|
||||
- 用途:TickTick OAuth redirect callback
|
||||
- Query 参数:
|
||||
- `state`
|
||||
- `code`
|
||||
- 响应:
|
||||
- 成功:纯文本 `Authorization successful`
|
||||
- 失败:纯文本错误信息,HTTP 400 或 500
|
||||
- 鉴权:
|
||||
- 通过 OAuth `state` 与进程内保存的 `authState` 做校验
|
||||
- 没有额外的 session 或用户级鉴权
|
||||
- 外部调用方:TickTick OAuth redirect
|
||||
- 副作用:
|
||||
- 用 authorization code 换取 access token
|
||||
- 通过 `viper.WriteConfig()` 把 `ticktick.token` 写回 YAML 配置文件
|
||||
|
||||
## 外部集成盘点
|
||||
|
||||
### TickTick
|
||||
|
||||
- 在 Python 重构中的状态:应保留
|
||||
- 主要文件:
|
||||
- [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:1)
|
||||
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:100)
|
||||
- 当前职责:
|
||||
- 初始化 TickTick 鉴权状态
|
||||
- 当 token 缺失时启动 OAuth 授权
|
||||
- 接收 OAuth callback 并持久化 token
|
||||
- 读取 project 下的 tasks
|
||||
- 若不存在同名任务,则创建新任务
|
||||
- 连接方式:
|
||||
- OAuth authorization code flow
|
||||
- 调用 `https://ticktick.com/oauth/token`
|
||||
- 调用 `https://api.ticktick.com/open/v1/...`
|
||||
- 依赖的配置项:
|
||||
- `ticktick.clientId`
|
||||
- `ticktick.clientSecret`
|
||||
- `ticktick.redirectUri`
|
||||
- `ticktick.token`
|
||||
- 关键实现依赖:
|
||||
- 原生 `net/http`
|
||||
- `viper`,同时承担配置读取和配置回写
|
||||
- 迁移高风险点:
|
||||
- OAuth callback 的 `state` 只保存在进程内;如果服务在授权开始和回调完成之间重启,流程会断
|
||||
- token 直接写回 YAML 配置文件,虽然简单,但运维上比较脆弱
|
||||
- 去重逻辑只按 task title 精确匹配
|
||||
- due date 的计算语义是隐含在代码里的,重构前应先冻结
|
||||
- `Init()` 会在启动时积极检查配置,且在 token 缺失时打印手动授权 URL;Python 版需要明确是否仍要在启动阶段卡住这一流程
|
||||
|
||||
### Home Assistant
|
||||
|
||||
- 在 Python 重构中的状态:应保留
|
||||
- 主要文件:
|
||||
- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:1)
|
||||
- [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:1)
|
||||
- 当前职责:
|
||||
- 接收来自 Home Assistant automations 的命令 envelope
|
||||
- 将命令转发给本地模块
|
||||
- 把 sensor state 发布回 Home Assistant
|
||||
- 在 poo 记录后触发 Home Assistant webhook
|
||||
- 连接方式:
|
||||
- 入站 webhook 风格 JSON 接口:`POST /homeassistant/publish`
|
||||
- 出站 REST 调用:Home Assistant `/api/states/{entity_id}`
|
||||
- 出站 webhook 调用:`/api/webhook/{webhook_id}`
|
||||
- 出站调用使用 bearer token
|
||||
- 依赖的配置项:
|
||||
- `homeassistant.ip`
|
||||
- `homeassistant.port`
|
||||
- `homeassistant.authToken`
|
||||
- `homeassistant.actionTaskProjectId`
|
||||
- `pooRecorder.webhookId`
|
||||
- `pooRecorder.sensorEntityName`
|
||||
- `pooRecorder.sensorFriendlyName`
|
||||
- 关键实现依赖:
|
||||
- 原生 `net/http`
|
||||
- 通过 `localhost:{port}` 发起自调用,而不是直接走函数调用
|
||||
- 迁移高风险点:
|
||||
- 入站 `/homeassistant/publish` 当前没有鉴权
|
||||
- 当前命令 envelope 里的 `content` 是字符串,且常带单引号,现有客户端可能依赖这种非标准格式
|
||||
- 模块间当前是通过自调用 HTTP 和 1 秒 timeout 编排的
|
||||
- sensor 发布和 webhook 触发都属于强副作用行为,需要在兼容性测试里单独覆盖
|
||||
|
||||
### Notion
|
||||
|
||||
- 在 Python 重构中的状态:当前存在,但按已知目标不计划默认保留
|
||||
- 主要文件:
|
||||
- [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:1)
|
||||
- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191)
|
||||
- helper CLI:[`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21)
|
||||
- 当前职责:
|
||||
- 使用 config token 初始化 Notion client
|
||||
- 读取 / 写入 poo 记录对应的表格行
|
||||
- 每日做 SQLite 和 Notion 的双向同步
|
||||
- 提供一个反转 Notion 表顺序的辅助 CLI
|
||||
- 连接方式:
|
||||
- 通过 `github.com/jomei/notionapi` 调用 Notion API
|
||||
- token 鉴权
|
||||
- 依赖的配置项:
|
||||
- `notion.token`
|
||||
- `pooRecorder.tableId`
|
||||
- 关键实现依赖:
|
||||
- `github.com/jomei/notionapi`
|
||||
- 迁移高风险点:
|
||||
- 当前服务启动时如果缺少 `notion.token` 会直接退出,即便 Notion 并不是系统所有功能都需要的基础能力
|
||||
- `POST /poo/record` 当前要求 `pooRecorder.tableId` 存在,并会异步镜像到 Notion
|
||||
- 每日定时同步会同时改写 Notion 和 SQLite,若 Python 版移除这一行为,数据一致性预期会发生变化
|
||||
|
||||
## 数据库与 Schema 盘点
|
||||
|
||||
### 数据库类型
|
||||
|
||||
- 当前使用 SQLite 做组件级持久化
|
||||
- poo recorder 中显式导入了 SQLite driver,见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:20)
|
||||
- location recorder 也通过 driver 名 `sqlite` 打开 SQLite,见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:80)
|
||||
|
||||
### Poo recorder 数据库
|
||||
|
||||
- 配置项:`pooRecorder.dbPath`
|
||||
- 默认路径:`pooRecorder.db`
|
||||
- migration 机制:
|
||||
- 手写 `PRAGMA user_version`
|
||||
- 当前有效版本可以认为是 `1`
|
||||
- 目前只实现了 `0 -> 1`
|
||||
- 表:
|
||||
- `poo_records`
|
||||
- schema 定义见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:162)
|
||||
- 字段:
|
||||
- `timestamp TEXT PRIMARY KEY`
|
||||
- `status TEXT NOT NULL`
|
||||
- `latitude REAL NOT NULL`
|
||||
- `longitude REAL NOT NULL`
|
||||
- 核心用途:
|
||||
- 用于查询最新 poo 状态并发布到 Home Assistant sensor
|
||||
- 作为本地 poo 历史的持久化来源
|
||||
- 作为 Notion 双向同步的本地数据源和数据汇
|
||||
- 明显核心字段:
|
||||
- `timestamp`
|
||||
- `status`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- 可能属于历史包袱 / 后续需要再判断的点:
|
||||
- 当前实现与 Notion 表行结构高度耦合
|
||||
- 时间戳是字符串,格式为 `2006-01-02T15:04Z07:00`,不是带秒的完整 RFC3339
|
||||
- API 请求模型接受的经纬度是字符串,因此 Python 版的类型规范化要小心兼容
|
||||
|
||||
### Location recorder 数据库
|
||||
|
||||
- 配置项:`locationRecorder.dbPath`
|
||||
- 默认路径:`location_recorder.db`
|
||||
- migration 机制:
|
||||
- 手写 `PRAGMA user_version`
|
||||
- 当前版本 `2`
|
||||
- 已实现 migration:
|
||||
- `0 -> 1`:建表
|
||||
- `1 -> 2`:把旧 datetime 字符串改写成 RFC3339 UTC
|
||||
- 表:
|
||||
- `location`
|
||||
- schema 定义见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:115)
|
||||
- 字段:
|
||||
- `person TEXT NOT NULL`
|
||||
- `datetime TEXT NOT NULL`
|
||||
- `latitude REAL NOT NULL`
|
||||
- `longitude REAL NOT NULL`
|
||||
- `altitude REAL`
|
||||
- 主键 `(person, datetime)`
|
||||
- 核心用途:
|
||||
- 持久化人生轨迹 / 位置点记录
|
||||
- 明显核心字段:
|
||||
- `person`
|
||||
- `datetime`
|
||||
- `latitude`
|
||||
- `longitude`
|
||||
- 可能属于历史包袱 / 后续需要再判断的点:
|
||||
- `altitude` 在语义上是可选,但当前入站解析会把缺失和非法值一起压成 `0`
|
||||
- 当前没有查询 API,这张表目前主要承担“只写不读”的存储角色
|
||||
|
||||
### 跨模块数据库观察
|
||||
|
||||
- 当前没有统一的共享 schema;每个组件各自打开自己的 SQLite 文件
|
||||
- 没有使用 ORM
|
||||
- 没有统一 migration 框架
|
||||
- 除主键外,没有看到额外索引
|
||||
- `poo` 的常规写入和异步 Notion 镜像之间没有事务保证,一致性更接近 best-effort
|
||||
|
||||
## 业务模块拆分
|
||||
|
||||
### 1. HTTP 外壳 / 应用启动层
|
||||
|
||||
- 职责:
|
||||
- 读取配置
|
||||
- 设置日志级别
|
||||
- 管理 scheduler 生命周期
|
||||
- 注册路由
|
||||
- 处理优雅退出
|
||||
- 主要文件:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62)
|
||||
- 依赖:
|
||||
- 所有业务模块
|
||||
- 迁移判断:
|
||||
- 这部分后续应成为 FastAPI 的 app 装配层
|
||||
- 可以在早期先迁为“薄壳”
|
||||
|
||||
### 2. Poo recorder
|
||||
|
||||
- 职责:
|
||||
- 接收 poo 记录
|
||||
- 持久化本地 poo 历史
|
||||
- 向 Home Assistant 发布最新状态 sensor
|
||||
- 触发可选 Home Assistant webhook
|
||||
- 与 Notion 做同步
|
||||
- 主要文件:[`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:50)
|
||||
- 依赖:
|
||||
- SQLite
|
||||
- Home Assistant util
|
||||
- Notion util
|
||||
- scheduler
|
||||
- 迁移判断:
|
||||
- 这是功能上重要、但耦合也最重的模块
|
||||
- 适合在设计上先拆成:
|
||||
- poo API / service
|
||||
- Home Assistant 发布适配层
|
||||
- legacy Notion sync adapter
|
||||
|
||||
### 3. Location recorder
|
||||
|
||||
- 职责:
|
||||
- 接收位置更新
|
||||
- 持久化人生轨迹点
|
||||
- 主要文件:[`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:39)
|
||||
- 依赖:
|
||||
- SQLite
|
||||
- 迁移判断:
|
||||
- 相对独立
|
||||
- 很适合作为优先迁移对象
|
||||
- 但需要先明确数值校验规则,因为当前实现会把非法数字静默压成 `0`
|
||||
|
||||
### 4. Home Assistant 命令路由层
|
||||
|
||||
- 职责:
|
||||
- 接收命令 envelope
|
||||
- 根据 target/action 调度 poo、location、ticktick 行为
|
||||
- 主要文件:[`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36)
|
||||
- 依赖:
|
||||
- 本地服务端口
|
||||
- TickTick util
|
||||
- 迁移判断:
|
||||
- 它是外部 automations 的关键契约
|
||||
- 应在迁移早中期就被冻结和复刻
|
||||
|
||||
### 5. TickTick adapter
|
||||
|
||||
- 职责:
|
||||
- OAuth callback
|
||||
- project / task REST 操作
|
||||
- 任务去重
|
||||
- 主要文件:[`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:81)
|
||||
- 依赖:
|
||||
- TickTick API
|
||||
- 可写配置文件
|
||||
- 迁移判断:
|
||||
- 作为内部 adapter 相对独立
|
||||
- 复杂度中等,主要难点是 OAuth 和 token 持久化
|
||||
|
||||
### 6. Home Assistant 出站 client
|
||||
|
||||
- 职责:
|
||||
- 发布 sensor state
|
||||
- 触发 webhook
|
||||
- 主要文件:[`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:30)
|
||||
- 依赖:
|
||||
- Home Assistant API/token
|
||||
- 迁移判断:
|
||||
- 小而独立
|
||||
- 很适合作为较早迁移的适配层
|
||||
|
||||
### 7. Notion adapter 与辅助 CLI
|
||||
|
||||
- 职责:
|
||||
- 读写 Notion table rows
|
||||
- 维护 poo 相关表格的辅助操作
|
||||
- 主要文件:
|
||||
- [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:14)
|
||||
- [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21)
|
||||
- 迁移判断:
|
||||
- 当前存在,但按已知方向应视为 planned non-migration
|
||||
- 更适合被标记为 legacy 模块,而不是直接带进 Python 主体
|
||||
|
||||
### 8. 辅助 helper CLI
|
||||
|
||||
- `src/helper/poo_recorder_helper`
|
||||
- 用于 Notion 表反转的运维辅助工具
|
||||
- `src/helper/location_recorder`
|
||||
- 当前基本还是脚手架,没有实质业务逻辑
|
||||
- 迁移判断:
|
||||
- 二者都不是后端重构的核心目标
|
||||
- `poo_recorder_helper` 应随着 Notion 一并视作 legacy
|
||||
|
||||
## 运行方式与部署形态
|
||||
|
||||
### 配置方式
|
||||
|
||||
- 配置文件名:`config.yaml`
|
||||
- 搜索路径:
|
||||
- 当前工作目录
|
||||
- `$HOME/.config/home-automation`
|
||||
- 配置加载代码:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65)
|
||||
- 开启了 `viper.WatchConfig()`
|
||||
|
||||
代码中实际出现的配置项包括:
|
||||
|
||||
- `port`
|
||||
- `logLevel`
|
||||
- `notion.token`
|
||||
- `ticktick.clientId`
|
||||
- `ticktick.clientSecret`
|
||||
- `ticktick.redirectUri`
|
||||
- `ticktick.token`
|
||||
- `homeassistant.ip`
|
||||
- `homeassistant.port`
|
||||
- `homeassistant.authToken`
|
||||
- `homeassistant.actionTaskProjectId`
|
||||
- `pooRecorder.tableId`
|
||||
- `pooRecorder.webhookId`
|
||||
- `pooRecorder.sensorEntityName`
|
||||
- `pooRecorder.sensorFriendlyName`
|
||||
- `pooRecorder.dbPath`
|
||||
- `locationRecorder.dbPath`
|
||||
|
||||
### 进程模型
|
||||
|
||||
- 单个二进制,通过 Cobra 子命令 `serve` 启动
|
||||
- 监听 `SIGINT` / `SIGTERM` 做优雅退出
|
||||
- scheduler 与 HTTP server 在同一进程内运行
|
||||
- 路由层使用 `gorilla/mux`
|
||||
|
||||
### 定时任务
|
||||
|
||||
- 每天 `0 5 * * *` 执行一次 poo records 与 Notion 的同步
|
||||
- 定义在 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180)
|
||||
|
||||
### 被动接收式接口
|
||||
|
||||
- 上面列出的入站 REST API
|
||||
- TickTick OAuth callback endpoint
|
||||
|
||||
### 运行时依赖的外部服务
|
||||
|
||||
- Home Assistant HTTP API 与 webhook
|
||||
- TickTick OAuth 与 REST API
|
||||
- 当前 poo 流程仍依赖 Notion API
|
||||
- 本地可写文件系统,用于配置文件和 SQLite DB
|
||||
|
||||
### 当前本地 / 服务部署形态
|
||||
|
||||
- 安装脚本会构建 Go 二进制,并安装到 `$HOME/.local/home-automation-backend`
|
||||
- 使用 Supervisor 管理进程
|
||||
- 生成的 supervisor 配置最终执行 `{binary} serve`
|
||||
- 参考:
|
||||
- [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45)
|
||||
- [`helper/home_automation_backend_template.conf`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/home_automation_backend_template.conf:1)
|
||||
|
||||
### 容器化情况
|
||||
|
||||
- 这一轮代码扫描中没有发现 `Dockerfile` 或 compose 文件
|
||||
- 当前部署形态是 supervisor-based,而不是 container-based
|
||||
|
||||
## 测试与文档现状
|
||||
|
||||
### 测试现状
|
||||
|
||||
- 只发现一个测试文件:[`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1)
|
||||
- 当前测试覆盖:
|
||||
- 入站命令 JSON 解码
|
||||
- target/action 路由分发
|
||||
- 转发到 poo/location handler 的行为
|
||||
- TickTick task 创建委托
|
||||
- 错误日志和失败路径
|
||||
- 当前没有覆盖:
|
||||
- poo recorder 的 DB 行为
|
||||
- location recorder 的 DB 行为
|
||||
- TickTick OAuth 流程
|
||||
- Home Assistant 出站发布
|
||||
- Notion sync 逻辑
|
||||
- 启动与配置加载
|
||||
- scheduler 行为
|
||||
|
||||
### 本轮测试执行情况
|
||||
|
||||
- 我尝试在 `legacy/go-backend/src/` 下执行 `go test ./...`
|
||||
- 但当前会话环境里没有安装 `go` 命令,因此无法实际运行测试
|
||||
- 所以这轮关于测试的判断,基于静态阅读,而不是实际执行结果
|
||||
|
||||
### 文档现状
|
||||
|
||||
- 仓库里的 `README.md` 基本只有标题和 badge,内容非常少
|
||||
- 没有用户可读的 API 文档
|
||||
- 没有 schema 文档
|
||||
- 没有 Home Assistant / TickTick 的契约说明文档
|
||||
- 没有关于配置项、OAuth 初始化、数据库文件位置的运维文档
|
||||
|
||||
### 后续最需要补齐的文档
|
||||
|
||||
- Home Assistant 命令 envelope 与支持的 action
|
||||
- 各 API 的 request / response 契约
|
||||
- TickTick OAuth 初始化与 token 持久化方式
|
||||
- 数据库归属、用途与保留策略
|
||||
- Notion 下线 / 不迁移说明
|
||||
|
||||
## 对 Python 重构特别重要的事实
|
||||
|
||||
- 当前 API 行为整体比较“轻响应、重副作用”,很多成功请求返回的都是空响应体
|
||||
- 当前所有入站 API 都没有看到鉴权
|
||||
- 当前系统本地真相来源是 SQLite,但 poo 数据还同时与 Notion 同步
|
||||
- `notion.token` 现在不是可选项,缺失时服务会在 `initUtil()` 阶段直接退出
|
||||
- Home Assistant 命令路由当前是通过本地 HTTP 自调用实现的,而不是直接服务层调用
|
||||
- TickTick callback 会改写应用本身使用的 YAML 配置文件
|
||||
@@ -0,0 +1,127 @@
|
||||
# 设计文档与多模型协作约定
|
||||
|
||||
本目录把 `docs/roadmap.md` 里的三个里程碑展开成**可被 coding agent 流水线执行**的详细设计文档。
|
||||
|
||||
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
|
||||
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
|
||||
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
|
||||
|
||||
本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。
|
||||
|
||||
---
|
||||
|
||||
## 1. 协作模型:Orchestrator → Implementer → Reviewer
|
||||
|
||||
设计目标是让三个不同档位的模型分工协作:
|
||||
|
||||
| 角色 | 模型档位 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| **Orchestrator(编排者)** | 强模型 | 读里程碑文档,挑出依赖已满足的下一个原子任务,派发给 implementer;收到 reviewer 的 PASS 后推进下一个任务 |
|
||||
| **Implementer(实现者)** | 便宜模型 | 一次只做**一个**原子任务,严格按任务卡执行,跑完本地校验后回报 |
|
||||
| **Reviewer(评审者)** | 强模型 | 对照验收标准 + Reviewer checklist 独立复核、独立跑校验闸门,返回 `PASS` 或一份编号返工清单 |
|
||||
|
||||
### 循环
|
||||
|
||||
```
|
||||
Orchestrator 选任务 T(其 Depends 全部为 done)
|
||||
│
|
||||
▼
|
||||
Implementer 实现 T ──► 跑校验闸门 ──► 回报 diff + 校验输出
|
||||
│
|
||||
▼
|
||||
Reviewer 复核 T
|
||||
├── PASS ─────────► Orchestrator 标记 T 为 done,进入下一个任务
|
||||
└── REWORK[1..n] ─► 退回 Implementer,按编号逐条修,直到 PASS
|
||||
```
|
||||
|
||||
### 角色边界(重要)
|
||||
|
||||
- **Implementer 不得扩大范围**:只能改任务卡 `Files` 里列出的文件;超出范围的问题要在回报里以 `OUT-OF-SCOPE:` 标注,交给 Orchestrator 决定是否新开任务,而不是顺手改掉。
|
||||
- **Reviewer 必须独立重跑校验闸门**,不能只信 implementer 的回报。
|
||||
- **Reviewer 的返工清单必须可执行、带编号**(`REWORK 1: ...`),不写主观感受。
|
||||
- 一个任务**不通过校验闸门就不算完成**,Orchestrator 不得跳过。
|
||||
|
||||
---
|
||||
|
||||
## 2. 原子任务的定义
|
||||
|
||||
一个"原子任务"必须同时满足:
|
||||
|
||||
1. **单一关注点**:一个任务只解决一件事(一次 schema 变更、一个端点、一个模块迁移……)。
|
||||
2. **PR 大小**:理想 diff < ~200 行(结构性 sweep 任务可放宽,但应在卡上标 `[structural]` 并优先派给较强 implementer)。
|
||||
3. **边界处可绿**:任务完成时,整个仓库通过校验闸门(见下)。**一个任务"拥有"它所改代码对应的测试**——如果改动会让某些现有测试失败,修这些测试就属于这个任务的范围,不允许留红给下一个任务。
|
||||
4. **可独立验收**:验收标准是客观、可机械检查的断言,不依赖人的主观判断。
|
||||
5. **依赖显式**:通过 `Depends` 字段声明前置任务。没有声明依赖的任务,Orchestrator 可并行派发。
|
||||
|
||||
---
|
||||
|
||||
## 3. 任务卡格式
|
||||
|
||||
每个任务在里程碑文档中以如下结构出现。Implementer 和 Reviewer 都只需要任务卡 + 本约定文件,**不需要读其它任务**即可工作。
|
||||
|
||||
```markdown
|
||||
### M{n}-T{nn} — <标题> [structural?]
|
||||
|
||||
- **Status**: `todo` | `in-progress` | `in-review` | `done`
|
||||
- **Depends**: M{n}-T{nn}, …(或 `none`)
|
||||
- **Context**: 1–2 句,为什么要做这个、它在里程碑里的位置。
|
||||
|
||||
**Files**(精确到路径,标注动作)
|
||||
- `create path/to/new_file.py`
|
||||
- `modify path/to/existing.py`
|
||||
- `delete path/to/old.py`
|
||||
|
||||
**Steps**(便宜模型可直接照做的有序步骤)
|
||||
1. …
|
||||
2. …
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- …(明确列出容易被误改的相邻区域,约束便宜模型漂移)
|
||||
|
||||
**Acceptance criteria**(客观、可勾选;Reviewer 逐条核)
|
||||
- [ ] …
|
||||
- [ ] 校验闸门全绿(见 §4)
|
||||
|
||||
**Reviewer checklist**(除验收标准外,强模型重点看的点)
|
||||
- …
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 校验闸门(每个任务结束都要全绿)
|
||||
|
||||
在仓库根目录、激活 `.venv` 后执行:
|
||||
|
||||
```bash
|
||||
# 1) 单元 / 集成测试(CI 同款,权威闸门)
|
||||
pytest
|
||||
|
||||
# 2) Lint(pyproject 已配置 ruff,line-length=100)
|
||||
ruff check .
|
||||
|
||||
# 3) 若本任务改动了任何 HTTP 路由 / schema:重导出 OpenAPI 并确认已提交
|
||||
python scripts/export_openapi.py
|
||||
git diff --exit-code openapi/ # 必须无未提交差异
|
||||
```
|
||||
|
||||
- `pytest` 是**权威闸门**:`.github/workflows/pytest.yml` 跑的就是它,任何任务都不得让它变红。
|
||||
- 改了路由 / Pydantic schema 的任务,`openapi/openapi.json` 和 `openapi/openapi.yaml` 必须在同一任务里重新生成并提交(`openapi/` 纳入版本控制)。
|
||||
- 前端(M2/M3)相关任务的前端侧闸门(lint / typecheck / build)在对应里程碑文档里单独定义。
|
||||
|
||||
---
|
||||
|
||||
## 5. 提交与集成约定
|
||||
|
||||
- 每个任务一个 commit,message 前缀任务 ID,例如:`M1-T03: unify data layer onto single app DB engine`。
|
||||
- 一个里程碑在一个 feature 分支上推进(如 `feature/m1-db-consolidation`),按任务依赖顺序合并。
|
||||
- 任务卡里的 `Status` 字段由 Orchestrator 维护,作为流水线的单一进度源。
|
||||
- 涉及**不可逆 / 数据破坏**的步骤(删旧 DB 文件、删 Grafana volume 等)一律不进自动化任务,只在文档里标为人工步骤(见 M1 的"人工操作"小节)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据安全红线(贯穿所有里程碑,不可违反)
|
||||
|
||||
1. **任何脚本 / migration 都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、确认无误的独立步骤。
|
||||
2. 涉及历史数据的迁移**先在备份副本上演练**,再对真实库执行。
|
||||
3. 数据迁移脚本必须**幂等**且**搬完对账行数**,对不上立即中止并非零退出。
|
||||
4. 破坏性 Reviewer 一票否决:只要任务里出现"删文件 / drop 有数据的表 / truncate",Reviewer 直接 REWORK,要求改为人工步骤。
|
||||
@@ -0,0 +1,348 @@
|
||||
# M1 — 单库化地基(DB Consolidation)
|
||||
|
||||
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本文档只展开 M1 的现状、目标与原子任务。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
把 location、poo 两个独立 SQLite 库合并进 `app.db`,收敛成**单库 + 单 engine + 单 DeclarativeBase + 单 Alembic 链**,清理项目早期散落的数据层代码,并移除 Grafana。历史数据零丢失。
|
||||
|
||||
## 2. 现状(实现者可据此工作,不必重新通读全仓库)
|
||||
|
||||
**三套数据层(散落点)**
|
||||
- `app/db.py`:`Base` + `engine`/`SessionLocal`/`get_db_session` —— 实际绑定 `settings.location_database_url`(即 location 库,命名有误导性)。
|
||||
- `app/models/base.py`:仅 `from app.db import Base` 转出。
|
||||
- `app/poo_db.py`:`PooBase` + `poo_engine`/`PooSessionLocal`/`get_poo_db_session` —— 绑定 `poo_database_url`。
|
||||
- `app/auth_db.py`:`AuthBase` + 带 `lru_cache` 的 `_get_auth_engine` / `get_auth_session_local` / `reset_auth_db_caches` / `get_auth_db_session` —— 绑定 `app_database_url`(真正的 app 库)。
|
||||
|
||||
**模型与归属**
|
||||
- `app/models/auth.py`:`AuthUser`、`AuthSession`(`AuthBase` → app 库)
|
||||
- `app/models/config.py`:`AppConfigEntry`(`AuthBase` → app 库)
|
||||
- `app/models/public_ip.py`:`PublicIPState`、`PublicIPHistory`(`AuthBase` → app 库)
|
||||
- `app/models/location.py`:`Location`(`Base` → location 库),表 `location`,PK(`person`,`datetime`),`latitude`/`longitude` NOT NULL,`altitude` nullable
|
||||
- `app/models/poo.py`:`PooRecord`(`PooBase` → poo 库),表 `poo_records`,PK(`timestamp`),`status`/`latitude`/`longitude` NOT NULL
|
||||
- `app/models/__init__.py`:导出除 `PooRecord` 外的模型(`PooRecord` 单独存在)
|
||||
|
||||
**三条 Alembic 链**
|
||||
- `alembic_app.ini` + `alembic_app/`(`env.py` 用 `AuthBase.metadata`),head = `20260429_05_public_ip_monitor`
|
||||
- `alembic_location.ini` + `alembic_location/`,head = `20260419_01_location_baseline`
|
||||
- `alembic_poo.ini` + `alembic_poo/`,head = `20260420_01_poo_baseline`
|
||||
|
||||
**adoption / 启动链路**
|
||||
- `scripts/app_db_adopt.py`(常量 `APP_BASELINE_REVISION = "20260429_05_public_ip_monitor"`)
|
||||
- `scripts/location_db_adopt.py`、`scripts/poo_db_adopt.py`(含 legacy 校验:`EXPECTED_USER_VERSION`、表结构断言)
|
||||
- `scripts/run_migrations.py`:依次调用三个 adopt 函数,返回 `{"app","location","poo"}`
|
||||
- `app/main.py` lifespan:`ensure_runtime_dirs`(app/location/poo 三路径)、`ensure_auth_db_ready`、`ensure_location_db_ready`、`ensure_poo_db_ready`,再起 APScheduler 每 4h 检查 public IP
|
||||
|
||||
**依赖与路由**
|
||||
- `app/dependencies.py`:`get_auth_db`(app session)、`get_db`(location session)、`get_poo_db`(poo session)、`get_app_settings`、`get_current_auth_session`、`get_homeassistant_client`、`get_ticktick_client`
|
||||
- `app/api/routes/location.py`:`POST /location/record`,依赖 `get_db`,**无鉴权**
|
||||
- `app/api/routes/poo.py`:`POST /poo/record`、`GET /poo/latest`,依赖 `get_poo_db`,**无鉴权**
|
||||
- `app/api/routes/homeassistant.py`:同时用 `get_db`(location)和 `get_poo_db`
|
||||
|
||||
**config**
|
||||
- `app/config.py`:`app_database_url` / `location_database_url` / `poo_database_url` 三字段 + computed `app_sqlite_path` / `location_sqlite_path` / `poo_sqlite_path`
|
||||
- `app/services/config_page.py`:`build_runtime_settings` 用到 `reset_auth_db_caches`;配置页 sections 暴露 `location_database_url` / `poo_database_url`(约 263–264 行)
|
||||
|
||||
**测试耦合点(M1 必然要改)**
|
||||
- `tests/conftest.py`:`test_database_urls` 设三套环境变量;`ready_location_database` / `ready_poo_database` / `auth_database` / `location_client`(monkeypatch `app_db.engine`/`SessionLocal`)/ `poo_client`(monkeypatch `poo_db.poo_engine`/`PooSessionLocal`)
|
||||
- `tests/test_location.py` / `tests/test_poo.py`:用上述 client + 各自 adopt 脚本的 adoption 测试
|
||||
- `tests/test_deployment.py`:断言 `run_all_migrations()` 返回 `{app,location,poo}` 三库各自 revision;断言 entrypoint 不含 `*_db_adopt`
|
||||
- `tests/test_homeassistant_inbound.py`:monkeypatch `app.poo_db`
|
||||
- `tests/test_config.py` / `tests/test_public_ip.py` / `tests/test_smtp.py`:硬编码三套 URL / 路径
|
||||
- `reset_auth_db_caches` 被 `conftest`、`test_app`、`test_auth`、`test_deployment`、`test_ticktick` 引用
|
||||
|
||||
## 3. 目标架构(M1 完成态)
|
||||
|
||||
**单数据层 `app/db.py`**
|
||||
```python
|
||||
class Base(DeclarativeBase): ...
|
||||
# 绑定 settings.app_database_url 的 cached engine;建连时启用 WAL(PRAGMA journal_mode=WAL)
|
||||
def get_engine() -> Engine: ...
|
||||
def get_session_local() -> sessionmaker: ...
|
||||
def reset_db_caches() -> None: ...
|
||||
def get_db_session() -> Generator[Session, None, None]: ...
|
||||
```
|
||||
- 所有模型(auth / config / public_ip / location / poo)都继承这一个 `Base`。
|
||||
- 删除 `app/auth_db.py`、`app/poo_db.py`、`app/models/base.py`。
|
||||
- 依赖收敛为**单一** `get_db`(app session);移除 `get_poo_db`、旧 `get_auth_db`。
|
||||
- 一条 Alembic 链(`alembic_app`),`location` / `poo_records` 成为其管理对象;删除 `alembic_location*` / `alembic_poo*`。
|
||||
- `config.py` 只保留 `app_database_url`;移除 location/poo 的 url 与 path。
|
||||
- `docker-compose.yml` 去掉 grafana service;删除 `grafana/`。
|
||||
- 数据搬迁由 `scripts/migrate_legacy_data.py` 一次性完成(不进 Alembic 链)。
|
||||
|
||||
## 4. 任务依赖图
|
||||
|
||||
```
|
||||
T01 (app 链建 location+poo 空表)
|
||||
├─► T02 (数据搬迁脚本) # 逻辑上需要新表存在
|
||||
└─► T03 [structural] (统一数据层/模型/依赖/路由)
|
||||
└─► T04 (lifespan + run_migrations 收敛, 删 adopt 脚本)
|
||||
└─► T05 (config 去 location/poo url + 配置页 + 测试硬编码)
|
||||
T06 (删 Grafana) # 独立, 可并行
|
||||
T07 (文档 + OpenAPI 重导出) # 收尾, 依赖 T03/T04/T05
|
||||
```
|
||||
|
||||
`T01`、`T06` 无前置可先开;`T02` 依赖 `T01`;`T03` 依赖 `T01`;`T04`/`T05` 依赖 `T03`;`T07` 最后。
|
||||
|
||||
---
|
||||
|
||||
## 5. 原子任务
|
||||
|
||||
### M1-T01 — app 链新增 revision:建 `location` + `poo_records` 空表 `[schema]`
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: none
|
||||
- **Context**: 让 app 库的 Alembic 链能建出这两张表,schema 与旧库**完全一致**。本任务只动 schema,不搬数据、不移模型。
|
||||
|
||||
**Files**
|
||||
- `create alembic_app/versions/20260611_06_merge_location_poo_tables.py`
|
||||
- `modify scripts/app_db_adopt.py`(更新 `APP_BASELINE_REVISION`)
|
||||
|
||||
**Steps**
|
||||
1. 新 revision:`revision = "20260611_06_merge_location_poo_tables"`,`down_revision = "20260429_05_public_ip_monitor"`。
|
||||
2. `upgrade()` 用 `op.create_table` 手写建 `location` 与 `poo_records`,列/约束严格照抄现有 baseline(`location`: person TEXT, datetime TEXT, latitude REAL NOT NULL, longitude REAL NOT NULL, altitude REAL nullable, PK(person,datetime);`poo_records`: timestamp TEXT, status TEXT, latitude REAL NOT NULL, longitude REAL NOT NULL, PK(timestamp))。
|
||||
3. `downgrade()`:`op.drop_table("poo_records")` + `op.drop_table("location")`。
|
||||
4. 把 `scripts/app_db_adopt.py` 的 `APP_BASELINE_REVISION` 更新为新 head。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- 不要把 `Location` / `PooRecord` 模型改到 app Base(那是 T03)。
|
||||
- 不要触碰 `alembic_location*` / `alembic_poo*`(T03/T04 删)。
|
||||
- 不要在本 revision 里写任何数据拷贝。
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] 在一个全新临时 app 库上 `command.upgrade(alembic_app head)` 后,`sqlite_master` 含 `location`、`poo_records`、且与旧 baseline 表结构一致(`PRAGMA table_info` 对齐)。
|
||||
- [ ] `downgrade -1` 能干净回滚这两张表。
|
||||
- [ ] `APP_BASELINE_REVISION == "20260611_06_merge_location_poo_tables"`。
|
||||
- [ ] 校验闸门全绿(`pytest` 中 `test_deployment` 对 app head 的断言仍通过,因为它用的是常量)。
|
||||
|
||||
**Reviewer checklist**
|
||||
- 表结构与旧 baseline **逐列逐约束**一致(类型 TEXT/REAL、nullable、PK 顺序)。
|
||||
- `down_revision` 正确指向旧 head,链上只有一个 head。
|
||||
|
||||
---
|
||||
|
||||
### M1-T02 — 数据搬迁脚本 `scripts/migrate_legacy_data.py`
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: M1-T01
|
||||
- **Context**: 把旧 `locationRecorder.db` / `pooRecorder.db` 的行幂等拷进 app 库的新表,搬完对账。**不进 Alembic 链**,人工运行一次。
|
||||
|
||||
**Files**
|
||||
- `create scripts/migrate_legacy_data.py`
|
||||
- `create tests/test_migrate_legacy_data.py`
|
||||
|
||||
**Steps**
|
||||
1. 入口 `migrate_legacy_data(app_url, location_url, poo_url, *, dry_run=False) -> dict`,CLI 默认从 env 读三个 url(即便 location/poo url 已从 `Settings` 移除,本脚本可直接读环境变量或接受 `--location-db`/`--poo-db` 参数,保持自包含)。
|
||||
2. 对每个旧库:若文件不存在 → 该表 `skipped`(**不报错**,保证 CI / 全新部署可安全 no-op)。
|
||||
3. 拷贝用 SQLite `ATTACH DATABASE '<old>' AS legacy` + `INSERT OR IGNORE INTO main.<table> SELECT <显式列> FROM legacy.<table>`(显式列名,禁用 `SELECT *`)。`INSERT OR IGNORE` 保证幂等(PK 冲突跳过)。
|
||||
4. 搬完对账:对每张表比对 `源行数` 与 `目标行数中来自源的部分`;目标行数 < 源行数则 `raise` 并以非零码退出。
|
||||
5. `dry_run` 模式只读统计、不写入。
|
||||
6. 打印每表结果:`{location: {source, copied, skipped, final}, poo_records: {...}}`。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- **绝不** `os.remove` / 覆盖任何旧文件(数据安全红线)。
|
||||
- 不修改 Alembic 链,不在 app 启动链路里调用本脚本。
|
||||
- 不改 `config.py`。
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] 单测:给定含 N 行的临时旧库 + 已 upgrade 的临时 app 库,运行后 app 库对应表有 N 行;**再运行一次**仍是 N 行(幂等)。
|
||||
- [ ] 单测:旧库文件不存在时该表返回 `skipped`,不抛异常,app 库该表保持为空。
|
||||
- [ ] 单测:构造"目标缺行"场景,断言对账失败抛错且退出码非零。
|
||||
- [ ] 脚本中不出现任何文件删除/覆盖调用(`grep -nE "os\.remove|unlink|shutil|truncate|DROP TABLE" scripts/migrate_legacy_data.py` 为空)。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
- 幂等机制确实是 PK 冲突安全(`INSERT OR IGNORE` 或等价 upsert),不是靠"先清空目标"。
|
||||
- 对账逻辑会在丢行时**真的中止**(非零退出),不是只打印 warning。
|
||||
- 列名显式,与两表 schema 完全对应。
|
||||
|
||||
---
|
||||
|
||||
### M1-T03 — 统一数据层、模型、依赖、路由到单库 `[structural]`
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: M1-T01
|
||||
- **Context**: M1 的核心 sweep。把三套 engine/Base/session 收敛成 `app/db.py` 一套(绑 app 库、开 WAL),所有模型挂到同一个 `Base`,依赖收敛为单一 `get_db`,所有路由改用它。**本任务必须原子落地**——删除旧模块会同时打断所有 importer,无法分多次保持绿色。Orchestrator 可按下方 Steps 的自然分段派给较强 implementer。
|
||||
|
||||
**Files**
|
||||
- `modify app/db.py`(改写为统一数据层:`Base` + 绑 `app_database_url` 的 cached engine + WAL + `get_session_local` + `reset_db_caches` + `get_db_session`)
|
||||
- `delete app/auth_db.py`
|
||||
- `delete app/poo_db.py`
|
||||
- `delete app/models/base.py`
|
||||
- `modify app/models/location.py`(`from app.db import Base`)
|
||||
- `modify app/models/poo.py`(改继承统一 `Base`,import 改 `app.db`)
|
||||
- `modify app/models/auth.py`、`app/models/config.py`、`app/models/public_ip.py`(`AuthBase` → 统一 `Base`)
|
||||
- `modify app/models/__init__.py`(补导出 `PooRecord`,保证 `from app import models` 注册所有表到同一 metadata)
|
||||
- `modify app/dependencies.py`(单一 `get_db`;删 `get_poo_db`;`get_app_settings`/`get_current_auth_session` 改用 `get_db`)
|
||||
- `modify app/api/routes/auth.py`、`pages.py`、`public_ip.py`、`ticktick.py`(`get_auth_db` → `get_db`)
|
||||
- `modify app/api/routes/location.py`、`poo.py`、`homeassistant.py`(location/poo session 改用 `get_db`;删 `get_poo_db` 引用)
|
||||
- `modify app/services/config_page.py`(`reset_auth_db_caches` → `reset_db_caches`)
|
||||
- `modify app/main.py`(`import app.auth_db as auth_db` → 统一层;`get_auth_session_local` → `get_session_local`)
|
||||
- `modify tests/conftest.py`、`tests/test_app.py`、`tests/test_auth.py`、`tests/test_ticktick.py`、`tests/test_homeassistant_inbound.py`、`tests/test_location.py`、`tests/test_poo.py`(import sweep + 把 location/poo client 改成写 app 库的统一 session;移除对 `app.poo_db`/`app.db`(location) monkeypatch 的依赖)
|
||||
|
||||
**Steps**
|
||||
1. 改写 `app/db.py`:`Base(DeclarativeBase)`;沿用 `auth_db.py` 的 cached-engine + reset 模式但绑 `app_database_url`;为 sqlite 连接注册 `PRAGMA journal_mode=WAL`(用 `event.listens_for(engine, "connect")` 或建连后执行)。导出 `get_engine`/`get_session_local`/`reset_db_caches`/`get_db_session`。
|
||||
2. 模型 sweep:所有 `from app.auth_db import AuthBase` / `from app.poo_db import PooBase` / `from app.db import Base` 统一成 `from app.db import Base`;类继承统一 `Base`。`app/models/__init__.py` 增加 `from app.models.poo import PooRecord` 并补进 `__all__`。
|
||||
3. 删 `app/auth_db.py`、`app/poo_db.py`、`app/models/base.py`。
|
||||
4. 依赖 sweep:`app/dependencies.py` 留单一 `get_db`(yield 统一 session),删 `get_poo_db`;`get_app_settings`、`get_current_auth_session` 的 `Depends(get_auth_db)` → `Depends(get_db)`。
|
||||
5. 路由 sweep:所有 `Depends(get_auth_db)`、`Depends(get_poo_db)`、`Depends(get_db)` 统一为 `Depends(get_db)`(变量名 `auth_db_session`/`poo_db`/`db` 可保留,不强制改)。
|
||||
6. `app/services/config_page.py`:`reset_auth_db_caches` → `reset_db_caches`。
|
||||
7. `app/main.py`:把 `_run_scheduled_public_ip_check` / `ensure_auth_db_ready` 里的 `auth_db.get_auth_session_local()` 换成统一 `get_session_local()`。(lifespan 里 location/poo 的 ready 检查留到 T04 删。)
|
||||
8. 测试 sweep:`reset_auth_db_caches` → `reset_db_caches`(6 个文件);conftest 的 `location_client`/`poo_client` 改成"写入统一 app session 即可"的形式(不再 monkeypatch 已删除的 `app.poo_db`/location `app.db`);`test_homeassistant_inbound` 同理。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- 不删 `scripts/location_db_adopt.py` / `scripts/poo_db_adopt.py`,不改 lifespan 的 location/poo ready 调用(那是 T04,避免与本任务交叉冲突)。
|
||||
- 不动 `config.py` 的字段(T05)。
|
||||
- 不改业务逻辑(service 内部算法、HA 集成行为保持不变)。
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] `grep -rnE "auth_db|poo_db|PooBase|AuthBase|get_auth_db|get_poo_db|reset_auth_db_caches|app\.models\.base" app | grep -v __pycache__` 结果为空。
|
||||
- [ ] `app/db.py` 的 engine 绑定 `app_database_url`,sqlite 下 `PRAGMA journal_mode` 实测为 `wal`。
|
||||
- [ ] 所有模型 `Base.metadata.tables` 同时包含 auth/config/public_ip/location/poo_records 五类表。
|
||||
- [ ] `pytest` 全绿(含 location/poo/homeassistant_inbound 测试在单库下通过)。
|
||||
- [ ] `ruff check .` 无新增告警。
|
||||
|
||||
**Reviewer checklist**
|
||||
- WAL 真的生效(实际连接 `PRAGMA journal_mode` 返回 `wal`),不是只写了注释。
|
||||
- location/poo 的读写在单库下行为不变(端点仍返回 200、行落库)。
|
||||
- 没有遗留指向已删模块的死 import;没有把业务逻辑顺手改了。
|
||||
- `get_db` 现在产出的是 app 库 session(不是旧 location 库)。
|
||||
|
||||
---
|
||||
|
||||
### M1-T04 — 收敛启动链路:lifespan + run_migrations,删除 location/poo adopt 脚本
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: M1-T03
|
||||
- **Context**: 单库后只需保证 app 库就绪;location/poo 的 adoption 链路整条退役。
|
||||
|
||||
**Files**
|
||||
- `modify app/main.py`(`ensure_runtime_dirs` 只建 app 路径;删 `ensure_location_db_ready`/`ensure_poo_db_ready` 及其调用与 import)
|
||||
- `modify scripts/run_migrations.py`(只 `adopt_or_initialize_app_db`,返回 `{"app": ...}`)
|
||||
- `delete scripts/location_db_adopt.py`
|
||||
- `delete scripts/poo_db_adopt.py`
|
||||
- `delete alembic_location.ini`、`alembic_location/`(含 env.py、versions)
|
||||
- `delete alembic_poo.ini`、`alembic_poo/`
|
||||
- `modify tests/test_deployment.py`(`run_all_migrations` 期望值改为单 `{"app": ...}`;删/改 legacy location/poo 迁移断言;保留"app DB 不存在则 fail-closed"用例)
|
||||
- `modify tests/test_location.py`、`tests/test_poo.py`(删除针对已删 adopt 脚本的 adoption 测试;保留端点行为测试)
|
||||
- `modify tests/conftest.py`(删 `_make_alembic_config`/`_make_poo_alembic_config`/`ready_location_database`/`ready_poo_database` 等已无意义的 fixture)
|
||||
|
||||
**Steps**
|
||||
1. `app/main.py`:移除 `from scripts.location_db_adopt ...` / `poo_db_adopt` import;删两个 `ensure_*_db_ready` 函数及 lifespan 中调用;`ensure_runtime_dirs` 只处理 `settings.app_sqlite_path`。
|
||||
2. `scripts/run_migrations.py`:`run_all_migrations` 只返回 app 一项。
|
||||
3. 删除两套 adopt 脚本与两套 alembic 环境/ini。
|
||||
4. 测试:把 `test_migration_runner_*` 改成单库口径;删掉引用已删脚本常量(`LOCATION_BASELINE_REVISION` 等)的用例。
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- 不动 `scripts/app_db_adopt.py` 的核心逻辑(仅 T01 已更新其常量)。
|
||||
- 不动数据搬迁脚本(T02)。
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] `grep -rnE "location_db_adopt|poo_db_adopt|alembic_location|alembic_poo" app scripts tests | grep -v __pycache__` 为空。
|
||||
- [ ] 仓库不再有 `alembic_location*` / `alembic_poo*` 文件。
|
||||
- [ ] `python -m scripts.run_migrations` 在全新临时 app 库上成功初始化(含 location/poo_records 表)。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
- lifespan 仍对 app 库 fail-closed(缺库时明确报错),未弱化启动安全。
|
||||
- 没有残留对已删 alembic 环境的引用(包括 `.ini` 路径字符串)。
|
||||
|
||||
---
|
||||
|
||||
### M1-T05 — config 去除 location/poo URL 与路径,清理配置页与测试硬编码
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: M1-T03
|
||||
- **Context**: 配置层只剩 `app_database_url`,运行时不再有 location/poo 库概念。
|
||||
|
||||
**Files**
|
||||
- `modify app/config.py`(删 `location_database_url`/`poo_database_url` 字段与 `location_sqlite_path`/`poo_sqlite_path` computed 属性)
|
||||
- `modify app/services/config_page.py`(配置页 sections 移除 `location_database_url`/`poo_database_url` 展示项)
|
||||
- `modify .env.example`(移除两行 legacy DB URL;保留 `APP_DATABASE_URL`)
|
||||
- `modify tests/test_config.py`(删对两个 URL/路径的断言)
|
||||
- `modify tests/test_public_ip.py`、`tests/test_smtp.py`(构造 `Settings` 时去掉 location/poo url 入参)
|
||||
- `modify tests/conftest.py`(`test_database_urls` 不再 set `LOCATION_DATABASE_URL`/`POO_DATABASE_URL`)
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- 不动 `migrate_legacy_data.py`(它自带读旧库路径的能力,与 `Settings` 解耦)。
|
||||
- 不改其它配置项(SMTP / TickTick / HA 等)。
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] `grep -rnE "location_database_url|poo_database_url|location_sqlite_path|poo_sqlite_path" app tests | grep -v __pycache__` 为空。
|
||||
- [ ] 配置页渲染不再出现 location/poo DB URL 字段。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
- 没有别的代码还假设 `Settings` 上存在这两个属性(运行期不会 AttributeError)。
|
||||
|
||||
---
|
||||
|
||||
### M1-T06 — 移除 Grafana
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: none(可与 T01 并行)
|
||||
- **Context**: 可视化将由 M2 的 React 承担;Grafana 直接删除,不再 re-point。
|
||||
|
||||
**Files**
|
||||
- `modify docker-compose.yml`(删 `grafana` service 及其 `depends_on`/挂载;删顶层 `volumes.homeautomation_grafana_storage`)
|
||||
- `delete grafana/`(`provisioning/`、`dashboards/` 全部)
|
||||
- `modify tests/test_deployment.py`(若有针对 grafana service 的断言则同步移除)
|
||||
- `modify README.md`(删"Grafana Provisioning"整节——也可并入 T07,二选一,避免重复改同段)
|
||||
|
||||
**Out of scope / 不要碰**
|
||||
- **不在脚本里删除** named volume `homeautomation_grafana_storage` 的实际数据卷——这是人工 ops 步骤(见 §6),compose 里移除声明即可。
|
||||
- 不动 app/migration service。
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] `docker-compose.yml` 不再含 `grafana` 与 `homeautomation_grafana_storage`。
|
||||
- [ ] 仓库不再有 `grafana/` 目录。
|
||||
- [ ] `docker compose config` 能成功解析(语法有效)。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
- 没有遗留对 `./grafana/...` 挂载路径的引用。
|
||||
- 没有顺手删 `./data` 卷或改动 app service 端口/卷。
|
||||
|
||||
---
|
||||
|
||||
### M1-T07 — 文档与 OpenAPI 收尾
|
||||
|
||||
- **Status**: `todo`
|
||||
- **Depends**: M1-T03, M1-T04, M1-T05
|
||||
- **Context**: 让文档反映单库现实,并把"前后端不分离 / 三库不合并 / Grafana"约束在 architecture 文档中正式退役。
|
||||
|
||||
**Files**
|
||||
- `modify README.md`(三库 → 单库;删 location/poo DB 初始化与 adopt 说明;更新"运行测试"段落使其与实际测试一致)
|
||||
- `modify docs/architecture-overview.md`(退役"三库不合并";location/poo Alembic 链合并说明)
|
||||
- `modify docs/roadmap.md`(勾掉 M1 范围项)
|
||||
- `run python scripts/export_openapi.py` 并提交 `openapi/` 变更(location/poo 路由依赖在 T03 改过,schema 可能变化)
|
||||
|
||||
**Acceptance criteria**
|
||||
- [ ] README / architecture 不再描述 location/poo 独立库与 adopt 脚本。
|
||||
- [ ] `python scripts/export_openapi.py` 后 `git diff --exit-code openapi/` 无未提交差异。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
**Reviewer checklist**
|
||||
- 文档无残留的旧命令(`location_db_adopt.py` 等)。
|
||||
- OpenAPI 已重导出且入库。
|
||||
|
||||
---
|
||||
|
||||
## 6. 人工操作 runbook(生产切换,不进自动化任务)
|
||||
|
||||
按数据安全红线,下列步骤由人执行,**不**写进 implementer 任务:
|
||||
|
||||
1. **备份**:停服前复制 `data/app.db`、`data/locationRecorder.db`、`data/pooRecorder.db` 到带时间戳的归档目录。
|
||||
2. **演练**:把上述备份恢复到 scratch 目录,先在副本上跑完整流程(升级 + `migrate_legacy_data.py --dry-run` 再实跑),核对行数。
|
||||
3. **部署新镜像**:新镜像的 migration job 会把 app 库升级到新 head,建出空的 `location` / `poo_records`。
|
||||
4. **搬数据**:在生产机运行 `python scripts/migrate_legacy_data.py`(指向归档前的旧库),核对对账输出。
|
||||
5. **验证**:app 起来后确认 location/poo 端点与历史查询正常、行数与旧库一致。
|
||||
6. **(事后,确认无误再做)撤旧库**:归档旧 `.db` 文件、删除 `homeautomation_grafana_storage` 卷。**这一步人工、可回退地保留归档,永不在脚本中自动执行。**
|
||||
|
||||
## 7. 里程碑完成定义(Definition of Done)
|
||||
|
||||
- 运行期只存在 `app.db` 一个库、一个 engine、一个 `Base`、一条 Alembic 链。
|
||||
- `grep` 不到任何 `auth_db` / `poo_db` / location 独立库 / adopt 脚本 / grafana 的残留引用。
|
||||
- 旧库历史数据已通过 `migrate_legacy_data.py` 搬入且对账通过。
|
||||
- `pytest`、`ruff check .`、`export_openapi` 全绿且 `openapi/` 已入库。
|
||||
- README / architecture / roadmap 反映单库现实。
|
||||
@@ -0,0 +1,250 @@
|
||||
# M2 — 前端 v2(React SPA)
|
||||
|
||||
> 阅读前提:先读 [`README.md`](./README.md)。M2 依赖 M1 完成(单库 + 干净的数据层 + API 建立在合并后的 schema 上)。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
用 **React SPA** 取代现有 Jinja 页面,由 FastAPI **同源**托管(同一容器、同一 origin)。一步合并 roadmap 的"前端重写"与"前端做厚":配置界面 + 数据可视化(热力图 / 地图,接管 Grafana)+ 记录的按需展示与小幅增删改。
|
||||
|
||||
> **元目标(agentic 实验)**:这是用 agent 写 React 的试水,全程尽量不读代码。因此本里程碑**强约束 OpenAPI → 类型化 TS client 作为契约护栏**:后端 API 先稳,前端永远对着强类型契约写,便宜模型不易跑偏,reviewer 也有客观依据。
|
||||
|
||||
## 2. 现状(M1 完成后)
|
||||
|
||||
- 页面仍是服务端 Jinja:`app/api/routes/pages.py`(`GET/POST /config`、`/`、`/admin`、`POST /config/smtp/test`)+ `app/templates/`(`base/config/home/login.html`、`styles.css`)。
|
||||
- 鉴权:`get_current_auth_session`(读 `auth_session_cookie_name` cookie),server-side session + 每会话 `csrf_token` 内嵌在表单。
|
||||
- `app/main.py` 已 `app.mount("/static", StaticFiles(...))`。
|
||||
- 配置读写逻辑在 `app/services/config_page.py`(`build_config_sections` / `save_config_updates` / `build_runtime_settings`)。
|
||||
- 业务数据:单库中的 `location`、`poo_records`、`public_ip_state`、`public_ip_history`。
|
||||
|
||||
## 3. 目标架构
|
||||
|
||||
### 3.1 后端:JSON API + SPA 托管
|
||||
|
||||
- 所有数据交互走 **JSON API**,统一前缀 `/api`(SPA 是客户端渲染,必须有 API——这与"同源/同容器"无关)。
|
||||
- FastAPI 既挂 `/api/*`,又挂 SPA 静态产物,并对非 `/api`、非静态资源的路径**回退到 `index.html`**(支持前端路由 deep-link)。
|
||||
- Jinja 页面在 SPA 达到功能对齐后移除。
|
||||
|
||||
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
|
||||
|
||||
- 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**(token 属 M3)。
|
||||
- CSRF(已定·简化版):依赖 `SameSite=Lax` 的 session cookie——跨站发起的写请求(POST/PUT/PATCH/DELETE)**不会自动携带 cookie**,经典 CSRF 主路已被堵;再要求所有写请求带一个**自定义 header**(跨站无 CORS 预检发不出,且本应用不对外站开放 CORS)作为纵深防御。**不做 per-session token 比对**(个人自用场景足够)。`GET /api/session` 仍保留,用途是返回当前登录用户、引导 SPA(不再以下发/校验 `csrf_token` 为目的)。
|
||||
- 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record`、`POST /poo/record`)维持现状到 M3**。
|
||||
|
||||
### 3.3 前端工程
|
||||
|
||||
- `frontend/`:**Vite + React + TypeScript**。
|
||||
- 组件库:**Mantine**(已定;批电池齐、TS 优先、视觉中性,最贴近此前 Vue 侧 Naive UI 的用法)。
|
||||
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装)。**生成物入库** + `npm run codegen` + CI 校验"生成物与 openapi 同步"(已定)。fetch 封装统一带 cookie、写请求注入自定义 CSRF header、401 跳登录。
|
||||
- 可视化:**Leaflet**(已定)—— `react-leaflet` + `leaflet.heat`(热力图,**头号功能**)+ `leaflet.markercluster`(点多时聚合)+ OSM 栅格瓦片(零 key)。**地图封在一个自包含组件后面**(如 `<RecordsMap points mode onSelect>`,全应用只此处 import leaflet),数据获取/时间窗 state 在外面;这样将来若要换 **MapLibre GL** 是被隔离的局部重写,不波及其它。
|
||||
- 状态/数据请求:轻量即可(**TanStack Query**,已定),不引入重型框架。
|
||||
|
||||
### 3.4 构建与部署
|
||||
|
||||
- 多阶段 `Dockerfile`:node 阶段 `npm ci && npm run build` → 把 `frontend/dist` 拷进 python 镜像的静态目录;运行镜像不带 node。
|
||||
- compose 仍是单 app 容器(同源)。
|
||||
|
||||
## 4. API 契约(M2 要落地的端点)
|
||||
|
||||
> 全部 `/api` 前缀、session 保护、JSON 进出。具体 schema 在各任务里用 Pydantic 定义,并经 `export_openapi.py` 固化。
|
||||
|
||||
| 分组 | 端点 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 会话 | `GET /api/session` | 返回当前用户 + csrf_token;未登录 401 |
|
||||
| 会话 | `POST /api/auth/login` | 账号密码登录,下发 session cookie |
|
||||
| 会话 | `POST /api/auth/logout` | 注销 |
|
||||
| 会话 | `POST /api/auth/password` | 改密(沿用现有强制改密语义)|
|
||||
| 配置 | `GET /api/config` | 返回配置 sections(secret 不回显)|
|
||||
| 配置 | `PUT /api/config` | 保存配置(留空保留旧 secret 语义不变)|
|
||||
| 配置 | `POST /api/config/smtp/test` | 触发测试发信 |
|
||||
| 数据 | `GET /api/locations` | location 记录查询(时间范围/分页,供地图/热力图)|
|
||||
| 数据 | `GET /api/poo` | poo 记录列表(分页)|
|
||||
| 数据 | `GET /api/public-ip` | 当前状态 + 变化历史 |
|
||||
| CRUD | `PATCH /api/locations/{person}/{datetime}` | 修正单条 location |
|
||||
| CRUD | `DELETE /api/locations/{person}/{datetime}` | 删除单条 location |
|
||||
| CRUD | `PATCH /api/poo/{timestamp}` | 修正单条 poo |
|
||||
| CRUD | `DELETE /api/poo/{timestamp}` | 删除单条 poo |
|
||||
|
||||
> 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`,poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。
|
||||
|
||||
## 5. 已锁定决策(讨论后拍板)
|
||||
|
||||
> 以下为与项目所有者讨论后**已定**的选择。**线框图本里程碑不画**——按本节 + 各任务卡描述,由实现侧自行合理排版(含移动端布局)。
|
||||
|
||||
**技术选型**
|
||||
1. **组件库 = Mantine**。批电池齐、TS 优先、视觉中性、文档好,最贴近此前 Naive UI 的用法,利于 agent 产出一致 UI。
|
||||
2. **地图库 = Leaflet**(`react-leaflet` + `leaflet.heat` + `leaflet.markercluster`,OSM 栅格、零 key)。**封在自包含组件后**,预留将来迁 MapLibre 的接缝(见 §3.3)。
|
||||
3. **OpenAPI client = 生成物入库** + `npm run codegen` + CI 校验"与 openapi 同步"。
|
||||
4. **CSRF = 简化版**:`SameSite=Lax` cookie + 写请求带自定义 header,**不做 per-session token**(见 §3.2)。
|
||||
5. **前端栈**:Vite + React + TS + TanStack Query + Mantine。
|
||||
6. **Jinja**:SPA 功能对齐后**全量移除** `templates/` 与 `pages.py`。
|
||||
|
||||
**信息架构 / UX**
|
||||
7. **首页主视图 = 地图(热力图为主)+ 时间范围选择器**。可视化优先级:**热力图(最重要)> 时间选择器(必须)> 散点点位/列表(辅助)**。
|
||||
8. **列表 = 辅助页面,分页**(默认页大小 ~100、有上限;前端换页取数,不拉全量)。
|
||||
9. **记录编辑/删除**:**location 靠点地图上的点**触发(不做 75k 行大列表);**poo 靠列表 + 地图点位**。
|
||||
10. **配置入口**:config 作为普通页之一,由界面上一个**齿轮图标**进入。`/admin`、`/` 现状只是重定向到 `/config`,SPA **不需要单独 admin 页**;`/` 首页直接给地图主视图(概览 dashboard 列为**可选/后续**,非 M2 核心)。
|
||||
11. **响应式 = 要**(手机浏览器可用、合理移动端布局)。**PWA** 列为近期 backlog(见 `docs/future-ideas.md`),M2 设计即按移动端友好铺路。
|
||||
|
||||
**范围边界**
|
||||
12. **CRUD = 改非主键字段 + 删单行**;主键(location=`person+datetime`、poo=`timestamp`)**不可改**;**不提供 UI 新建**(记录由设备 ingestion 产生)。
|
||||
13. **裸 ingestion 端点**(`POST /location/record`、`POST /poo/record`)**维持现状到 M3**,本里程碑不加保护、不改动。
|
||||
14. **trip / 轨迹连线**为**可选 / 后续**(5 分钟一点 + 手机记录较糙,先不做核心)。
|
||||
|
||||
> 项目定位:个人自用、家庭特化、不开源——设计可按单用户场景简化,不为通用性过度抽象。
|
||||
|
||||
## 6. 任务依赖图
|
||||
|
||||
```
|
||||
后端 API(可与前端 scaffold 并行)
|
||||
M2-T01 config API
|
||||
M2-T02 session/auth API ─┐
|
||||
M2-T03 data read API ├─► 都产出 OpenAPI 契约
|
||||
M2-T04 record CRUD API │
|
||||
M2-T05 smtp/action API ─┘
|
||||
│ (openapi 稳定后)
|
||||
▼
|
||||
M2-T06 前端 scaffold + codegen ──► M2-T07 auth UI
|
||||
├─► M2-T08 config UI
|
||||
├─► M2-T09 可视化 UI
|
||||
└─► M2-T10 records 管理 UI
|
||||
▼
|
||||
M2-T11 FastAPI 托管 SPA + 移除 Jinja(依赖 T07–T10 达到对齐)
|
||||
▼
|
||||
M2-T12 多阶段 Dockerfile + CI/compose
|
||||
▼
|
||||
M2-T13 文档 + OpenAPI 收尾
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 原子任务(任务卡)
|
||||
|
||||
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
|
||||
|
||||
### M2-T01 — config JSON API
|
||||
- **Status**: `done` · **Depends**: none(M1 完成后)
|
||||
- **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。
|
||||
- **Files**: `create app/api/routes/api/config.py`、`create app/schemas/config.py`;`modify app/main.py`(注册路由);`create tests/test_api_config.py`
|
||||
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`;session 保护;secret 不回显、留空保留旧值语义照搬。
|
||||
- **Acceptance**:
|
||||
- [ ] 未登录访问 `GET /api/config` 返回 401。
|
||||
- [ ] 登录后 `GET` 返回 sections,secret 字段被遮罩。
|
||||
- [ ] `PUT` 留空 secret 时保留旧值;非法值返回 4xx 且不写库。
|
||||
- [ ] 校验闸门全绿(含 `openapi/` 重导出入库)。
|
||||
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
|
||||
|
||||
### M2-T02 — session / auth JSON API
|
||||
- **Status**: `done` · **Depends**: none
|
||||
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
|
||||
- **Files**: `create app/api/routes/api/session.py`、`app/schemas/session.py`;`modify app/main.py`;`create tests/test_api_session.py`
|
||||
- **Steps**: `GET /api/session`(401 或 user+csrf)、`POST /api/auth/login`、`POST /api/auth/logout`、`POST /api/auth/password`,复用 `app/services/auth.py`。
|
||||
- **Acceptance**:
|
||||
- [ ] 正确账号密码登录后置下 HttpOnly session cookie;`GET /api/session` 返回 user + csrf_token。
|
||||
- [ ] 错误凭据 401,不下发 cookie。
|
||||
- [ ] 写端点缺 `X-CSRF-Token` 或不匹配 → 403。
|
||||
- [ ] 强制改密语义与现有一致。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env`、`SameSite=Lax`;密码仍 Argon2,不明文。
|
||||
|
||||
### M2-T03 — 数据读取 API(locations / poo / public-ip)
|
||||
- **Status**: `done` · **Depends**: none
|
||||
- **Files**: `create app/api/routes/api/data.py`、`app/schemas/data.py`;`modify app/main.py`;`create tests/test_api_data.py`
|
||||
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`(state + history);session 保护;查询参数有上限防全表导出。
|
||||
- **Acceptance**:
|
||||
- [ ] 分页/时间范围参数生效且有上限;越权未登录 401。
|
||||
- [ ] 返回 schema 经 OpenAPI 固化。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
|
||||
|
||||
### M2-T04 — 记录 CRUD API(修正 / 删除)
|
||||
- **Status**: `done` · **Depends**: M2-T03
|
||||
- **Files**: `modify app/api/routes/api/data.py`、`app/services/location.py`、`app/services/poo.py`;`create tests/test_api_record_crud.py`
|
||||
- **Steps**: `PATCH`/`DELETE` location(PK person+datetime)与 poo(PK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
|
||||
- **Acceptance**:
|
||||
- [ ] PATCH 改单行字段、DELETE 删单行,行数变化精确为 1。
|
||||
- [ ] 不存在的 PK → 404。
|
||||
- [ ] 缺 CSRF → 403。
|
||||
- [ ] 没有任何"批量删/清表"路径。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
|
||||
|
||||
### M2-T05 — SMTP 测试 / 动作类 JSON API
|
||||
- **Status**: `done` · **Depends**: M2-T01
|
||||
- **Files**: `modify app/api/routes/api/config.py`;`modify tests/test_api_config.py`
|
||||
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
|
||||
- **Acceptance**:
|
||||
- [ ] 三种结果都有明确 JSON 状态码/字段;session + CSRF 保护。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
|
||||
- **Status**: `done` · **Depends**: M2-T01..T05(OpenAPI 已稳定)
|
||||
- **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。
|
||||
- **Files**: `create frontend/`(Vite+React+TS 脚手架、`package.json`、`tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`(codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md`、`npm run codegen` 脚本
|
||||
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
|
||||
- **Acceptance**:
|
||||
- [ ] `npm ci && npm run build` 成功产出 `frontend/dist`。
|
||||
- [ ] `npm run lint`、`npm run typecheck`、`npm run test` 全绿(哪怕只有 1 个 smoke 测试)。
|
||||
- [ ] `npm run codegen` 生成物与当前 `openapi/openapi.json` 一致(CI 可校验)。
|
||||
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
|
||||
|
||||
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
|
||||
- **Status**: `done` · **Depends**: M2-T06
|
||||
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
|
||||
|
||||
### M2-T08 — 配置 UI(取代 Jinja config 页)
|
||||
- **Status**: `done` · **Depends**: M2-T06
|
||||
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
|
||||
|
||||
### M2-T09 — 数据可视化 UI(热力图为主的地图)
|
||||
- **Status**: `done` · **Depends**: M2-T06(数据来自 T03)
|
||||
- **Context**: 接管 Grafana 原职责,且**首页主视图就是这张地图**。优先级:**① 热力图(最重要)② 时间范围选择器(必须)③ 散点点位(辅助,主要服务编辑/删除)**。location:去过哪的密度;poo:狗最爱在哪拉。
|
||||
- **Acceptance**: 首页渲染热力图(location / poo);**时间范围选择器生效、只取窗口内数据**(不拉全量);散点层可切换、点选某点可进入编辑/删除(接 T10/T04);location 点多时聚合;响应式(手机浏览器可用);前端闸门全绿。
|
||||
|
||||
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
|
||||
- **Status**: `done` · **Depends**: M2-T06(CRUD 来自 T04)
|
||||
- **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。
|
||||
|
||||
### M2-T11 — FastAPI 托管 SPA + 移除 Jinja
|
||||
- **Status**: `done` · **Depends**: M2-T07, T08, T09, T10
|
||||
- **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/`、`app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试)
|
||||
- **Acceptance**:
|
||||
- [ ] `/config` 等路径返回 SPA(`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。
|
||||
- [ ] 旧 Jinja 模板与 pages 路由移除后 `pytest` 全绿。
|
||||
- [ ] 校验闸门全绿(含 OpenAPI 重导出)。
|
||||
- **Reviewer**: fallback 不拦截 `/api`、`/docs`、`/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。
|
||||
|
||||
### M2-T12 — 多阶段 Dockerfile + CI/compose
|
||||
- **Status**: `done` · **Depends**: M2-T11
|
||||
- **Files**: `modify Dockerfile`(node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新)
|
||||
- **Acceptance**:
|
||||
- [ ] 镜像构建成功且运行镜像不含 node 运行时。
|
||||
- [ ] CI 跑前端闸门 + 后端 `pytest`。
|
||||
- [ ] 校验闸门全绿。
|
||||
|
||||
### M2-T13 — 文档 + OpenAPI 收尾
|
||||
- **Status**: `done` · **Depends**: M2-T12
|
||||
- **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2;`openapi/` 已同步入库。
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端校验闸门(前端任务每次结束都要全绿)
|
||||
|
||||
在 `frontend/` 下:
|
||||
```bash
|
||||
npm ci
|
||||
npm run codegen # 生成类型化 client;产物须与 openapi/openapi.json 同步
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build # 必须产出 dist
|
||||
```
|
||||
- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`。
|
||||
- "codegen 产物与 OpenAPI 同步"应在 CI 校验(生成后 `git diff --exit-code`)。
|
||||
|
||||
## 9. 里程碑完成定义(DoD)
|
||||
|
||||
- 访问应用得到 React SPA;配置、可视化、记录增删改都在 SPA 内完成。
|
||||
- 所有浏览器交互走 `/api` JSON 端点,session + CSRF 保护;ingestion 裸端点维持现状(留给 M3)。
|
||||
- Jinja `templates/` 与 pages 路由移除;FastAPI 同源托管 SPA。
|
||||
- 多阶段镜像构建通过;CI 含前端闸门。
|
||||
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `build/lint/typecheck/test` 全绿。
|
||||
@@ -0,0 +1,109 @@
|
||||
# M3 — Token 鉴权与移动端(远期试水)
|
||||
|
||||
> 阅读前提:先读 [`README.md`](./README.md)。M3 依赖 M2(已有 `/api` JSON 契约与 session 鉴权)。
|
||||
>
|
||||
> **定位**:远期、低投入、探索性。React Native 部分主要是"没做过、试试水"。范围**可能收缩**——其中**token 鉴权 + ingestion 端点收口**是有持久价值的安全改进,应优先;RN app 是加分项。Orchestrator 可只取前半。
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 引入 **bearer token 鉴权**,让非浏览器客户端(移动端、设备脚本)能安全访问。
|
||||
2. 把 M2 暂时维持裸奔的 **ingestion 端点**(`POST /location/record`、`POST /poo/record`)收口到 token 鉴权下。
|
||||
3. 做一个 **React Native** 移动端,用类 OAuth 流程拿 token 后消费现有 `/api`。
|
||||
|
||||
## 2. 现状(M2 完成后)
|
||||
|
||||
- `/api/*` 走 session cookie + `X-CSRF-Token`。
|
||||
- `app/services/auth.py` 有 server-side session(`auth_sessions` 表,token_hash 存储)。
|
||||
- `POST /location/record`、`POST /poo/record` 仍**无鉴权**(设备/脚本裸调用)。
|
||||
|
||||
## 3. 目标架构
|
||||
|
||||
### 3.1 Token 模型
|
||||
- 新表 `auth_tokens`:`id`、`user_id`、`token_hash`(仅存哈希)、`label`(设备名)、`created_at`、`expires_at`(可空=长期)、`revoked_at`。
|
||||
- bearer 校验:`Authorization: Bearer <token>` → 哈希比对 `auth_tokens` → 命中且未撤销未过期则认定身份。
|
||||
|
||||
### 3.2 类 OAuth 签发流程(无第三方的 Authorization Code 简化版)
|
||||
1. 移动端在**内置浏览器**打开 `/authorize?...`。
|
||||
2. 用户账号密码登录(走现有 session),页面展示"授权此设备"。
|
||||
3. 批准后服务端生成**一次性 authorization code**,重定向到 app 深链 `homeautomation://callback?code=...`。
|
||||
4. app 用 code 调 `POST /api/auth/token` 换取 bearer token 并存本地。
|
||||
> 简化兜底:批准页直接展示一次性 token 由 app 捕获。优先实现重定向 + code 交换的正规版。
|
||||
|
||||
### 3.3 统一鉴权依赖
|
||||
- `/api` 的数据/CRUD 端点接受**session cookie 或 bearer**两者之一(同一套端点同时服务 Web 与移动端)。
|
||||
- ingestion 端点(location/poo record)改为**要求 bearer**。
|
||||
|
||||
### 3.4 React Native
|
||||
- **Expo + TypeScript**,复用 M2 的 OpenAPI 类型化 client(共享契约)。
|
||||
- 内置浏览器走 §3.2 流程拿 token;之后所有请求带 `Authorization: Bearer`。
|
||||
|
||||
## 4. 迁移注意(重要)
|
||||
- ingestion 端点一旦要求 bearer,**现有调用方(HA/设备脚本)必须先配置 token**,否则记录会中断。
|
||||
- 上线顺序:先签发 token 能力(T01–T02)→ 给现有设备配 token → 再对 ingestion 端点强制 bearer(T03),避免断流。可设一个过渡开关或灰度。
|
||||
|
||||
## 5. 任务依赖图
|
||||
```
|
||||
M3-T01 token 模型 + 迁移
|
||||
└─► M3-T02 签发流程(authorize + code 交换)
|
||||
└─► M3-T03 统一鉴权依赖 + ingestion 端点收口(含过渡开关)
|
||||
├─► M3-T04 Web 端 token 管理 UI(列出/撤销设备)
|
||||
└─► M3-T05 React Native app(试水)
|
||||
└─► M3-T06 文档收尾
|
||||
```
|
||||
|
||||
## 6. 原子任务(任务卡)
|
||||
|
||||
### M3-T01 — token 数据模型 + Alembic 迁移
|
||||
- **Status**: `todo` · **Depends**: none(M2 完成后)
|
||||
- **Files**: `create app/models/token.py`、`alembic_app/versions/<new>_auth_tokens.py`;`modify app/models/__init__.py`;`create tests/test_token_model.py`
|
||||
- **Acceptance**:
|
||||
- [ ] 迁移在全新库 upgrade 后建出 `auth_tokens` 表;downgrade 可回滚。
|
||||
- [ ] token 仅以哈希存储(与 `auth_sessions` 同等强度),明文不入库。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer**: 哈希算法/长度与现有 session token 一致;`expires_at` 可空语义明确。
|
||||
|
||||
### M3-T02 — 签发流程:authorize 页 + code 交换端点
|
||||
- **Status**: `todo` · **Depends**: M3-T01
|
||||
- **Files**: `create app/api/routes/api/token.py`、`app/schemas/token.py`;前端 `/authorize` 页(M2 SPA 内);`create tests/test_api_token.py`
|
||||
- **Acceptance**:
|
||||
- [ ] 登录用户在 `/authorize` 批准后得到一次性 code;`POST /api/auth/token` 用 code 换取 bearer,code 一次性且短时效。
|
||||
- [ ] 未登录访问 `/authorize` 跳登录;无效/过期 code 换取失败。
|
||||
- [ ] 返回的 bearer 仅此一次明文出现,库中只存哈希。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer**: code 一次性、绑定用户、短 TTL;深链 redirect 白名单校验,防开放重定向。
|
||||
|
||||
### M3-T03 — 统一鉴权依赖 + ingestion 端点收口
|
||||
- **Status**: `todo` · **Depends**: M3-T02
|
||||
- **Files**: `modify app/dependencies.py`(新增"cookie 或 bearer"统一身份依赖);`modify app/api/routes/location.py`、`poo.py`(要求 bearer,带过渡开关);`modify tests`
|
||||
- **Acceptance**:
|
||||
- [ ] `/api` 数据/CRUD 端点用合法 bearer 可访问(等价于 session)。
|
||||
- [ ] ingestion 端点:带合法 bearer 通过,缺/错 token 在强制模式下 401;过渡开关可临时放行(默认关)。
|
||||
- [ ] 撤销的 token 立即失效。
|
||||
- [ ] 校验闸门全绿。
|
||||
- **Reviewer**: 过渡开关默认安全(强制);bearer 与 session 两路鉴权不产生绕过;ingestion 行为变更有测试覆盖。
|
||||
|
||||
### M3-T04 — Web 端 token 管理 UI
|
||||
- **Status**: `todo` · **Depends**: M3-T03
|
||||
- **Acceptance**: 在 SPA 内可列出已签发设备 token(label/创建时间/最近使用)、可撤销;撤销后该 token 立即失效;前端闸门全绿。
|
||||
|
||||
### M3-T05 — React Native app(试水)
|
||||
- **Status**: `todo` · **Depends**: M3-T03 · `[experimental]`
|
||||
- **Files**: `create mobile/`(Expo + TS,复用 OpenAPI 类型化 client)
|
||||
- **Acceptance**:
|
||||
- [ ] 内置浏览器走签发流程拿到 token 并安全存储(Keychain/Keystore)。
|
||||
- [ ] 至少跑通:登录拿 token → 拉取一类数据展示 → 记一条 ingestion。
|
||||
- [ ] `npm run lint`/`typecheck`/`build`(或 Expo 等价) 全绿。
|
||||
- **Reviewer**: token 存安全存储而非明文;client 基于共享 OpenAPI 类型。
|
||||
|
||||
### M3-T06 — 文档收尾
|
||||
- **Status**: `todo` · **Depends**: M3-T05
|
||||
- **Acceptance**: README/architecture 增 token 鉴权与移动端说明;roadmap 勾选 M3;`openapi/` 同步。
|
||||
|
||||
## 7. 里程碑完成定义(DoD)
|
||||
- 存在 bearer token 鉴权与签发流程;token 仅哈希存储、可撤销。
|
||||
- ingestion 端点已收口到 bearer(过渡完成后强制)。
|
||||
- `/api` 同时支持 session 与 bearer。
|
||||
- (加分)React Native app 能拿 token 并消费 `/api`。
|
||||
- 后端 + 前端 + 移动端各自校验闸门全绿,`openapi/` 入库。
|
||||
|
||||
> 提醒:本里程碑探索性强,T05 可作为独立试水随时叫停,不影响 T01–T04 带来的安全收口价值。
|
||||
@@ -0,0 +1,20 @@
|
||||
# Future Ideas / Backlog(暂无 Milestone)
|
||||
|
||||
记录尚未排期的想法。等某条成形、值得集中推进时,再升级为 `docs/roadmap.md` 里的 milestone 并展开成 `docs/design/` 任务卡。**这里只是备忘,不是承诺。**
|
||||
|
||||
> 项目定位:**个人自用、针对自家场景特化,不开源**。因此设计可按单用户 / 自家需求简化,不必为通用性、多租户、对外发布做过度抽象。
|
||||
|
||||
## 数据与存储
|
||||
- 增加更多数据类型 / 来源(持续扩展)。
|
||||
- 针对**需要长期保存**的数据,考虑更合适的存储方案(当前全 SQLite;长期 / 大量数据可能需要更强的数据库)。
|
||||
- 把 **Home Assistant 接收到的数据**纳入本系统做持久化 / 展示。
|
||||
|
||||
## 集成
|
||||
- **MQTT**:让后端作为一个 MQTT client,双向收发数据。
|
||||
|
||||
## 前端 / 移动端
|
||||
- **PWA**(**近期、可能并入 M2 或单独小里程碑**):在 React Native(M3)之前,用 PWA 把 web SPA 包装成"准手机 App"——可安装到桌面、响应式、离线壳。
|
||||
- 影响当下设计:**M2 的 UI 从一开始就按移动端布局考虑**(响应式 + 合理的参数显示),为之后加 PWA 铺路。
|
||||
|
||||
## 备注
|
||||
- 以上为临时记录(讨论 M2 范围时随手想到),后续可增删、重排优先级。
|
||||
@@ -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 的响应体保持简洁,不暴露过多内部细节;更详细原因只写日志。
|
||||
@@ -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 的出站契约和可复用结构迁进来。
|
||||
@@ -1,6 +1,13 @@
|
||||
# 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 事实基线
|
||||
|
||||
@@ -31,16 +38,51 @@ PRAGMA user_version = 2;
|
||||
|
||||
1. 把上述 `location` schema 视为 Alembic baseline
|
||||
2. 新数据库通过 Alembic `upgrade head` 初始化
|
||||
3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,就通过 `alembic stamp` 接管
|
||||
4. 未来不再以 `PRAGMA user_version` 作为主 migration 机制
|
||||
3. 已有 legacy SQLite 数据库,只要确认 schema 与 baseline 一致,再通过 `alembic stamp` 接管
|
||||
4. 如果数据库已经存在 `alembic_version`,则必须先确认当前 revision 与项目预期 baseline 一致
|
||||
5. 只有 revision 一致时,才视为该库已经被正确接管
|
||||
6. 未来不再以 `PRAGMA user_version` 作为主 migration 机制
|
||||
|
||||
当前 baseline revision 是:
|
||||
|
||||
- `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
|
||||
alembic upgrade head
|
||||
@@ -52,9 +94,15 @@ alembic upgrade head
|
||||
|
||||
对于已经存在的 legacy SQLite 数据库:
|
||||
|
||||
1. 先确认其 `location` 表 schema 与 baseline 一致
|
||||
2. 旧库里的 `PRAGMA user_version = 2` 仅视为历史事实,不再继续沿用
|
||||
3. 确认无误后,对该数据库执行 `stamp`,而不是重新跑创建表 migration
|
||||
1. 先确认 DB 文件存在
|
||||
2. 如果已经存在 `alembic_version` 表,则先读取当前 revision
|
||||
3. 如果 revision 等于 `20260419_01_location_baseline`,则视为该库已经被 Alembic 正确接管
|
||||
4. 如果 revision 不匹配,立即报错并停止,不做任何自动修复
|
||||
5. 如果还没有 `alembic_version` 表,则读取当前 DB 中 `location` 表的实际 schema
|
||||
6. 与 baseline schema 做严格比对
|
||||
7. 再检查 `PRAGMA user_version`
|
||||
8. 只有 schema 匹配且 `user_version = 2` 时,才执行 Alembic `stamp`
|
||||
9. 接管完成后,后续 migration 才交给 Alembic 管理
|
||||
|
||||
示例:
|
||||
|
||||
@@ -62,12 +110,38 @@ alembic upgrade head
|
||||
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 20260419_01_location_baseline
|
||||
```
|
||||
|
||||
或直接执行脚本:
|
||||
|
||||
```bash
|
||||
LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db python scripts/location_db_adopt.py
|
||||
```
|
||||
|
||||
这样做的含义是:
|
||||
|
||||
- 告诉 Alembic:这个数据库已经处于 baseline 结构
|
||||
- 不修改已有 `location` 表数据
|
||||
- 后续 migration 由 Alembic 接管
|
||||
|
||||
## Fail Closed 原则
|
||||
|
||||
当前策略是保守接管,不做未知 legacy 状态的自动修复。
|
||||
|
||||
如果出现以下任一情况,脚本会直接报错并停止:
|
||||
|
||||
- 找不到 `location` 表
|
||||
- `location` 表 schema 与 baseline 不一致
|
||||
- `PRAGMA user_version` 不等于 `2`
|
||||
- 已有 `alembic_version`,但 revision 与预期 baseline 不一致
|
||||
- 目标 DB 不是 SQLite URL
|
||||
|
||||
当前不会尝试:
|
||||
|
||||
- 自动修表
|
||||
- 自动调整 `user_version`
|
||||
- 自动推断未知 legacy 状态
|
||||
|
||||
如果发生这些情况,应先人工确认数据库状态,再决定是否需要单独迁移或修复。
|
||||
|
||||
## 关于 `data/locationRecorder.db`
|
||||
|
||||
你本地放在 `data/locationRecorder.db` 的 legacy 样本库,可以用于:
|
||||
@@ -91,6 +165,12 @@ LOCATION_DATABASE_URL=sqlite:///./data/locationRecorder.db alembic stamp 2026041
|
||||
- 构造一个“legacy 风格”的临时 SQLite 文件
|
||||
- 建出同样的 `location` 表
|
||||
- 设置 `PRAGMA user_version = 2`
|
||||
- 再执行 Alembic `stamp`
|
||||
- 再执行接管脚本中的 adopt 逻辑
|
||||
|
||||
同时也覆盖:
|
||||
|
||||
- DB 文件不存在时的新库初始化路径
|
||||
- schema 不匹配时的失败路径
|
||||
- `user_version` 不匹配时的失败路径
|
||||
|
||||
这样可以验证接管路径,同时不污染真实样本库。
|
||||
|
||||
@@ -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
|
||||
@@ -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 实现,整个重构路线就会可控很多。
|
||||
@@ -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 就继续保留兼容层
|
||||
@@ -0,0 +1,126 @@
|
||||
# Public IPv4 Monitor 与邮件通知
|
||||
|
||||
本文档说明当前 public IPv4 monitor 与 SMTP 邮件通知能力的职责边界和运行方式。
|
||||
|
||||
## 当前范围
|
||||
|
||||
当前实现只覆盖一个很小的通知能力:
|
||||
|
||||
- 定期或手动检查当前公网 IPv4
|
||||
- 将当前状态和变化历史持久化到 app DB
|
||||
- 仅在公网 IPv4 发生变化时发送一封英文纯文本邮件
|
||||
|
||||
当前明确不包含:
|
||||
|
||||
- Namecheap API 自动更新
|
||||
- IPv6 检查
|
||||
- 错误告警邮件
|
||||
- 重复提醒 / 升级告警
|
||||
- Telegram / Slack / Discord 通知
|
||||
- 完整通知中心或模板系统
|
||||
|
||||
## 数据存储
|
||||
|
||||
当前数据全部进入 app DB。
|
||||
|
||||
相关表:
|
||||
|
||||
- `public_ip_state`
|
||||
- 保存当前状态
|
||||
- 逻辑上通常只有一行
|
||||
- `public_ip_history`
|
||||
- 保存首次发现和变化历史
|
||||
|
||||
当前不会把 public IP 状态放进 `app_config`。
|
||||
|
||||
## 检查结果语义
|
||||
|
||||
一次检查会返回以下四种结果之一:
|
||||
|
||||
- `first_seen`
|
||||
- `unchanged`
|
||||
- `changed`
|
||||
- `error`
|
||||
|
||||
行为约束:
|
||||
|
||||
- `first_seen`:写入当前 IP 和首条 history,但不发通知邮件
|
||||
- `unchanged`:只更新时间和状态,不写 history,不发邮件
|
||||
- `changed`:更新 `previous_ipv4` / `current_ipv4` / `last_changed_at`,写入 history,并发送邮件
|
||||
- `error`:保留已有有效 IP,不写伪 history,也不发邮件
|
||||
|
||||
## 手动检查与定时检查
|
||||
|
||||
手动检查入口:
|
||||
|
||||
- `GET /public-ip/check`
|
||||
|
||||
约束:
|
||||
|
||||
- 需要现有鉴权
|
||||
- 响应不暴露 IP 本身
|
||||
- 只返回非敏感检查结果
|
||||
|
||||
定时检查:
|
||||
|
||||
- 应用启动时注册 APScheduler job
|
||||
- 默认每 4 小时执行一次
|
||||
- 与手动检查复用同一套 public IP check + notify 逻辑
|
||||
|
||||
## SMTP 通知
|
||||
|
||||
当前通知发信复用现有 SMTP sender。
|
||||
|
||||
依赖的配置项:
|
||||
|
||||
- `SMTP_ENABLED`
|
||||
- `SMTP_HOST`
|
||||
- `SMTP_PORT`
|
||||
- `SMTP_USERNAME`
|
||||
- `SMTP_PASSWORD`
|
||||
- `SMTP_FROM_NAME`
|
||||
- `SMTP_FROM_ADDRESS`
|
||||
- `SMTP_TO_ADDRESS`
|
||||
- `SMTP_USE_STARTTLS`
|
||||
|
||||
其中:
|
||||
|
||||
- `SMTP_FROM_NAME` 用于邮件头显示名
|
||||
- `From` 头会渲染成 `Name <mail@domain>`
|
||||
- SMTP envelope sender 仍然使用纯邮箱地址,保持兼容性
|
||||
|
||||
## 通知触发条件
|
||||
|
||||
只有在 `changed` 时发邮件。
|
||||
|
||||
不会发邮件的情况:
|
||||
|
||||
- `first_seen`
|
||||
- `unchanged`
|
||||
- `error`
|
||||
|
||||
这使得同一 IP 状态不会被重复通知,因为在首次变更之后,后续重复检查会变成 `unchanged`。
|
||||
|
||||
## 邮件内容
|
||||
|
||||
当前邮件标题固定为:
|
||||
|
||||
- `Public IP changed`
|
||||
|
||||
正文为英文纯文本,至少包含:
|
||||
|
||||
- previous IP
|
||||
- current IP
|
||||
- detected time
|
||||
|
||||
当前正文还会附带一句 Namecheap trusted IP 的人工更新提示。
|
||||
|
||||
## 失败处理
|
||||
|
||||
当前通知发送是“尽力而为”的附加动作:
|
||||
|
||||
- public IP 状态持久化先完成
|
||||
- 邮件发送失败不会回滚 public IP 状态
|
||||
- 失败只记录 warning 日志
|
||||
|
||||
这样可以避免通知链路反过来影响主检查流程。
|
||||
@@ -1,314 +0,0 @@
|
||||
# Python 重构方案
|
||||
|
||||
本文档基于当前 Go 实现,给出迁移到 Python + FastAPI 的设计输入与建议顺序。本文档只讨论迁移方案,不代表已经开始实现。
|
||||
|
||||
## 重构原则
|
||||
|
||||
- 以当前 Go 实现为唯一事实来源
|
||||
- 先保持对外行为兼容,再考虑内部清理和优化
|
||||
- 明确区分“行为兼容”和“内部实现升级”
|
||||
- 默认不把 Notion 纳入新的 Python 主版本目标,除非后续重新决策
|
||||
- 尽量按模块迁移,并在模块之间建立清晰契约
|
||||
|
||||
## 目标形态
|
||||
|
||||
建议的 Python 目标架构:
|
||||
|
||||
- 用 FastAPI 提供 HTTP 路由,并自然生成 OpenAPI
|
||||
- 业务逻辑按模块拆分到 service layer
|
||||
- 外部系统对接放到 adapter layer
|
||||
- SQLite 访问放到 repository layer
|
||||
- 配置采用显式 settings model
|
||||
- scheduler 是否内嵌在应用进程内,需要在启动语义明确后再决定
|
||||
- 仅保留极轻量的服务端页面,用于 OAuth 跳转或简单配置
|
||||
|
||||
## 建议的 Python 模块边界
|
||||
|
||||
### 应用外壳层
|
||||
|
||||
- 职责:
|
||||
- 读取 settings
|
||||
- 依赖注入与对象装配
|
||||
- 路由注册
|
||||
- lifespan hooks
|
||||
- scheduler 启停
|
||||
- 在 FastAPI 中可对应:
|
||||
- `main.py` 或 app factory
|
||||
- settings 类
|
||||
|
||||
### Poo 领域模块
|
||||
|
||||
- 职责:
|
||||
- 校验 poo record 输入
|
||||
- 持久化 poo record
|
||||
- 查询 latest poo
|
||||
- 通过接口触发外部副作用
|
||||
- 建议把副作用依赖抽象为端口:
|
||||
- `PooRepository`
|
||||
- `HomeAssistantPublisher`
|
||||
- `HomeAssistantWebhookClient`
|
||||
- 如有需要,可保留临时 `LegacyPooMirror` 作为 Notion 过渡适配器
|
||||
|
||||
### Location 领域模块
|
||||
|
||||
- 职责:
|
||||
- 校验并持久化位置点
|
||||
- 可保持简洁:
|
||||
- `LocationRepository`
|
||||
- `LocationService`
|
||||
|
||||
### Home Assistant 命令网关
|
||||
|
||||
- 职责:
|
||||
- 暴露 `/homeassistant/publish`
|
||||
- 解析 `target/action/content`
|
||||
- 将命令分发到内部服务
|
||||
- 兼容性注意:
|
||||
- 第一阶段应保留当前 `content` 的处理习惯,包括对字符串 payload 的兼容解析
|
||||
|
||||
### TickTick 集成模块
|
||||
|
||||
- 职责:
|
||||
- OAuth start / callback
|
||||
- token 存储
|
||||
- task 查询
|
||||
- 去重
|
||||
- task 创建
|
||||
- 建议:
|
||||
- 把 token persistence 抽象成独立能力,而不是把“改写配置文件”直接塞进业务逻辑
|
||||
|
||||
### Home Assistant 出站适配层
|
||||
|
||||
- 职责:
|
||||
- 发布 sensor state
|
||||
- 触发 webhook
|
||||
- 该层小而独立,适合较早迁移
|
||||
|
||||
### Legacy Notion 适配层
|
||||
|
||||
- 职责:
|
||||
- 只在分析或过渡阶段表示当前行为
|
||||
- 默认建议:
|
||||
- 不放入 Python 第一版正式目标
|
||||
- 如 cutover 期间确有需要,可以 feature flag 或独立迁移工具的方式暂存
|
||||
|
||||
## 实现前需要冻结的兼容契约
|
||||
|
||||
在正式编码前,建议先把以下当前行为写成明确契约:
|
||||
|
||||
### API 契约
|
||||
|
||||
- `POST /poo/record` 的请求字段与当前“成功时空响应体”的行为
|
||||
- `POST /location/record` 的请求字段与数值解析行为
|
||||
- `POST /homeassistant/publish` 的 envelope 格式与支持的 `target/action`
|
||||
- `GET /ticktick/auth/code` 的成功 / 失败语义
|
||||
|
||||
### 副作用契约
|
||||
|
||||
- `POST /poo/record` 在什么时机会发布 Home Assistant sensor
|
||||
- `POST /poo/record` 在什么条件下会触发 Home Assistant webhook
|
||||
- Home Assistant 消息如何映射为 TickTick task title 与 due date
|
||||
- Home Assistant sensor payload 的结构
|
||||
|
||||
### 持久化契约
|
||||
|
||||
- 当前 SQLite 表名与主键
|
||||
- 当前磁盘上的时间戳格式
|
||||
- Python 第一阶段是否直接复用现有 DB 文件,还是做显式迁移
|
||||
|
||||
## 建议的迁移决策
|
||||
|
||||
### 决策 1:第一阶段保持对外 API 形状不变
|
||||
|
||||
原因:
|
||||
|
||||
- 当前 API 面很小
|
||||
- 保持兼容能显著降低切换风险
|
||||
- 即使保持兼容,FastAPI 仍然可以生成 OpenAPI 文档
|
||||
|
||||
### 决策 2:把内部 self-HTTP 改成直接服务调用
|
||||
|
||||
原因:
|
||||
|
||||
- 当前 Go 代码中的 `localhost` 自调用,本质上是内部编排手段
|
||||
- 这不是一个必须暴露给外部的契约
|
||||
- Python 版改为直接函数 / service 调用,可以提升清晰度和可测试性
|
||||
|
||||
### 决策 3:先继续使用 SQLite
|
||||
|
||||
原因:
|
||||
|
||||
- 当前系统已经使用 SQLite
|
||||
- 数据模型规模很小
|
||||
- PostgreSQL 更适合作为 parity 之后的下一阶段演进
|
||||
|
||||
### 决策 4:默认不迁 Notion,但要明确记录影响
|
||||
|
||||
原因:
|
||||
|
||||
- 你已经明确表示 Notion 很可能不继续保留
|
||||
- 当前 Notion 不是“代码里有但没在用”,而是真正参与运行逻辑
|
||||
- 所以不能静默删除,而要在方案中写清楚删掉后有什么影响
|
||||
|
||||
### 决策 5:把 token / auth persistence 做成显式设计
|
||||
|
||||
原因:
|
||||
|
||||
- 当前 TickTick token 处理虽然可用,但运维上比较脆弱
|
||||
- Python 重构是一个把这件事规范化的机会
|
||||
|
||||
## 建议迁移顺序
|
||||
|
||||
### Phase 0:盘点与契约确认
|
||||
|
||||
- 完成当前系统 inventory
|
||||
- 确认哪些当前行为是“必须兼容的契约”,哪些只是历史偶然实现
|
||||
- 明确把 Notion 标为 non-migration scope,除非后续重新决定
|
||||
|
||||
### Phase 1:Python 骨架与通用基础设施
|
||||
|
||||
- 建立 FastAPI app shell
|
||||
- 定义 settings / config model
|
||||
- 定义日志方案
|
||||
- 定义 SQLite 访问方式
|
||||
- 定义测试框架与 fixture 策略
|
||||
- 定义 OpenAPI 生成与导出方式
|
||||
|
||||
这一阶段不需要大量迁移业务逻辑,只要搭好后续模块可持续迁入的基础即可。
|
||||
|
||||
### Phase 2:先迁最独立、最稳定的业务模块
|
||||
|
||||
推荐优先迁移:`location recorder`
|
||||
|
||||
原因:
|
||||
|
||||
- 独立 SQLite 表
|
||||
- 没有复杂外部副作用
|
||||
- 没有 OAuth
|
||||
- 没有 scheduler
|
||||
|
||||
这一阶段的交付物可以包括:
|
||||
|
||||
- `POST /location/record`
|
||||
- 与现有 SQLite 兼容的写入逻辑
|
||||
- 校验与 repository 的单元测试
|
||||
- 基于临时 SQLite 的 integration test
|
||||
|
||||
### Phase 3:迁移 Home Assistant 出站适配层
|
||||
|
||||
原因:
|
||||
|
||||
- 功能面小
|
||||
- 能为后面的 poo 迁移做铺垫
|
||||
|
||||
这一阶段的交付物可以包括:
|
||||
|
||||
- sensor publish client
|
||||
- webhook trigger client
|
||||
- 针对请求格式与错误处理的 mock tests
|
||||
|
||||
### Phase 4:迁移 TickTick adapter
|
||||
|
||||
原因:
|
||||
|
||||
- 相对自洽
|
||||
- 在完成 Home Assistant 命令网关前就需要它
|
||||
|
||||
这一阶段的交付物可以包括:
|
||||
|
||||
- OAuth callback endpoint
|
||||
- token persistence abstraction
|
||||
- task 创建与去重行为
|
||||
- 基于 mock HTTP 的集成式测试
|
||||
|
||||
### Phase 5:迁移 Home Assistant 命令网关
|
||||
|
||||
原因:
|
||||
|
||||
- 这是外部 automations 的核心编排入口
|
||||
- 在 location 与 TickTick adapter 准备好后,网关迁移会顺很多
|
||||
|
||||
这一阶段的交付物可以包括:
|
||||
|
||||
- `/homeassistant/publish`
|
||||
- 兼容当前 `target/action` 的分发逻辑
|
||||
- 用进程内 service 调用替代 self-HTTP
|
||||
- 把现有 Go 测试场景迁成 Python contract tests
|
||||
|
||||
### Phase 6:迁移 poo recorder 核心,但默认不带 Notion
|
||||
|
||||
原因:
|
||||
|
||||
- 这是最复杂的模块
|
||||
- 它既有本地 DB,又有 Home Assistant 副作用,当前还耦合 Notion
|
||||
|
||||
建议拆成两个子阶段:
|
||||
|
||||
- phase 6a:
|
||||
- 本地 poo DB
|
||||
- latest poo 查询
|
||||
- sensor publish
|
||||
- 可选 webhook trigger
|
||||
- `/poo/record`
|
||||
- `/poo/latest`
|
||||
|
||||
- phase 6b:
|
||||
- 如果 cutover 期间必须保留旧逻辑,再做一个临时 legacy Notion 兼容层
|
||||
|
||||
### Phase 7:运维加固与切换验证
|
||||
|
||||
- 做 Go / Python 路由级契约比对
|
||||
- 用现有 SQLite 文件或其副本做兼容验证
|
||||
- 在 staging 环境手动验证 TickTick OAuth
|
||||
- 用真实 Home Assistant automation payload 做验证
|
||||
- 导出 OpenAPI YAML
|
||||
- 再补容器化与部署方案
|
||||
|
||||
## 哪些模块适合先迁,哪些适合后迁
|
||||
|
||||
### 适合优先迁移
|
||||
|
||||
- location recorder
|
||||
- Home Assistant 出站 client
|
||||
- TickTick adapter
|
||||
|
||||
### 更适合后迁
|
||||
|
||||
- Home Assistant 命令网关
|
||||
- poo recorder 核心
|
||||
|
||||
### 现状存在,但建议不迁
|
||||
|
||||
- Notion sync adapter
|
||||
- `poo_recorder_helper` 的 Notion 表反转 CLI
|
||||
- `location_recorder` helper CLI 脚手架
|
||||
|
||||
## 建议的验证策略
|
||||
|
||||
### Contract tests
|
||||
|
||||
- 基于当前 Go 行为建立 request / response fixtures
|
||||
- 先把现有 `homeassistant` 测试案例迁成 Python
|
||||
- 补上 `poo` 与 `location` API 的契约测试
|
||||
|
||||
### Integration tests
|
||||
|
||||
- 每个模块使用临时 SQLite DB
|
||||
- Home Assistant 与 TickTick 出站流量通过 mock HTTP 替代
|
||||
- 若仍保留 scheduler,则为其补定时行为测试
|
||||
|
||||
### 手工 staging 验证
|
||||
|
||||
- TickTick OAuth callback
|
||||
- Home Assistant sensor 更新
|
||||
- Home Assistant webhook 触发
|
||||
- 当前真实自动化 payload 样例
|
||||
|
||||
## 开始实现前仍需明确的问题
|
||||
|
||||
- Python 第一阶段是否还要保留“缺少 `notion.token` 就启动失败”的行为,还是直接把 Notion 变成可关闭能力?
|
||||
- `POST /location/record` 是否要继续保留“非法数字静默变成 0”的兼容行为?
|
||||
- TickTick token 在第一阶段是否继续写回 YAML,还是立即切到独立 token store?
|
||||
- 当前 Home Assistant automations 是否真实依赖 `content` 中的单引号 pseudo-JSON?
|
||||
|
||||
这些问题不影响当前 inventory,但会影响第一阶段“兼容到什么程度”的具体定义。
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
# Roadmap
|
||||
|
||||
本文档记录 `home-automation` 在 `v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
|
||||
|
||||
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md):M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
|
||||
|
||||
## 当前基线(v1.0.3)
|
||||
|
||||
- FastAPI + 服务端 Jinja 模板页面(目前只有 `/login`、`/config`)
|
||||
- 三个独立 SQLite 库:
|
||||
- App DB:`sqlite:///./data/app.db`
|
||||
- Location DB:`sqlite:///./data/locationRecorder.db`
|
||||
- Poo DB:`sqlite:///./data/pooRecorder.db`
|
||||
- 三条独立 Alembic 链:`alembic_app/`、`alembic_location/`、`alembic_poo/`
|
||||
- 单 admin 鉴权(Argon2 + server-side session cookie)
|
||||
- Public IPv4 monitor、SMTP 通知、Location / Poo recorder、Home Assistant in/out、TickTick OAuth
|
||||
- 数据可视化目前由 Grafana provisioning 承担(仅 location / poo dashboard)
|
||||
- 已有 OpenAPI 导出脚本:`scripts/export_openapi.py`
|
||||
|
||||
## 本阶段正式退役的架构约束
|
||||
|
||||
`docs/architecture-overview.md` 里有几条当时刻意写死的约束,这一阶段明确退役:
|
||||
|
||||
- **“不引入前后端分离”** → 退役。本阶段改为 React SPA(仍由 FastAPI 同源托管,但渲染移到客户端)。
|
||||
- **“三个独立 DB 不合并”** → 退役。本阶段把 location / poo 合并进 `app.db`。
|
||||
- **Grafana 作为可视化方案** → 退役。可视化由 React 前端自己承担(热力图、地图等)。
|
||||
|
||||
保持不变的约束:
|
||||
|
||||
- 继续使用 **SQLite**,本阶段不上 Postgres。
|
||||
- 不引入 Notion。
|
||||
|
||||
## 里程碑总览
|
||||
|
||||
| 里程碑 | 主题 | 一句话 |
|
||||
| --- | --- | --- |
|
||||
| **M1** ✅ | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
|
||||
| **M2** ✅ | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
|
||||
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
|
||||
|
||||
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。
|
||||
|
||||
---
|
||||
|
||||
## M1 — 单库化地基(✅ 已完成)
|
||||
|
||||
### 目标
|
||||
|
||||
把 location / poo 两个独立库合并进 `app.db`,借机清理项目早期散落各处的数据访问代码,并移除 Grafana。
|
||||
|
||||
### 范围
|
||||
|
||||
- **Alembic 收敛为单链(app 链)**:location / poo 的表此后纳入 app 链管理;`alembic_location/`、`alembic_poo/` 退出活跃使用(保留在 git 历史)。
|
||||
- **新建表(schema only)**:在 app 链上加一条 upgrade revision,把原来两个旧库里的表**原样**建到 `app.db` 中。Alembic **不需要知道任何旧数据**——它只负责把 app DB 往上升一个版本、建出这两张新表。
|
||||
- **数据搬迁交给独立脚本**:`scripts/migrate_legacy_data.py`(见下方“迁移策略”),手动跑一次。
|
||||
- **配置层收敛**:去掉 `LOCATION_DATABASE_URL` / `POO_DATABASE_URL`,统一到 `APP_DATABASE_URL`。
|
||||
- **开启 SQLite WAL**:单文件 + Web + APScheduler 并发写入,开 WAL 更稳。
|
||||
- **删除 Grafana**:移除 compose 中的 grafana service、`grafana/provisioning/`、`grafana/dashboards/`。直接删除,不再 re-point datasource。
|
||||
- **更新文档**:README、architecture-overview 同步反映单库现实。
|
||||
|
||||
### 注意
|
||||
|
||||
- **可视化空窗可接受**:M1 删掉 Grafana 后、到 M2 React 可视化落地之前会有一段没有可视化面板的时间。已确认可以接受。
|
||||
- **历史数据是第一优先级,绝不能丢**(见“数据安全原则”)。
|
||||
|
||||
---
|
||||
|
||||
## 迁移策略(M1 核心)
|
||||
|
||||
职责拆分得很清楚:**Alembic 管 schema,脚本管数据。**
|
||||
|
||||
### Alembic revision(只建结构)
|
||||
|
||||
- 一条 app 链上的 upgrade revision,建出与旧库**完全相同**的表结构。
|
||||
- 确定性、与环境无关:在生产机、CI、全新部署上都一样地建空表,不依赖任何旧文件是否存在。
|
||||
- 本步**只原样挪表,不顺手改 schema**。任何表结构清理留到之后一条单独的 migration 去做——不可替代的历史数据,一次只承担一种风险。
|
||||
|
||||
### 数据搬迁脚本(`scripts/migrate_legacy_data.py`)
|
||||
|
||||
- 把旧 `locationRecorder.db` / `pooRecorder.db` 里的行,拷进 `app.db` 的新表(SQLite `ATTACH DATABASE` 或单独连接均可)。
|
||||
- **幂等**:重复运行不会重复插入。
|
||||
- **搬完对账**:逐表核对源 / 目标行数,对不上就报错中止。
|
||||
- 只在生产机上**手动跑一次**,不进 Alembic 永久链路(避免把一次性历史搬迁焊死进每次全新建库都要跑的链路里)。
|
||||
|
||||
### 旧库的“撤掉”
|
||||
|
||||
- “撤掉旧库” = ① 配置不再指向它们 + ② 文件**归档保留**。
|
||||
- **绝不**在任何脚本 / migration 里 `os.remove` 旧文件——那不可逆,且踩数据安全红线。
|
||||
- 真正的删除是**人工、最后、确认无误之后**单独的一步。
|
||||
|
||||
---
|
||||
|
||||
## 数据安全原则
|
||||
|
||||
历史数据(location / poo 记录)是这个项目里最不可替代的东西,迁移期间一律按以下原则:
|
||||
|
||||
1. **迁移前先归档**旧 `.db` 文件一份。
|
||||
2. **先在副本上演练**:把每日备份恢复到一个 scratch 目录,在副本上跑完整迁移、核对行数无误,再对真实库动手。
|
||||
3. **脚本幂等 + 行数对账**,对不上立即中止。
|
||||
4. **旧文件只读归档、绝不自动删除**,删除是事后人工动作。
|
||||
|
||||
---
|
||||
|
||||
## M2 — 前端 v2(React SPA)✅ 已完成
|
||||
|
||||
### 目标
|
||||
|
||||
用 React SPA 取代现有 Jinja 页面,由 FastAPI 同源托管(同一容器、同一 origin)。这一步合并了“前端重写为 React”和“前端做厚”两件原本分开的事——它们本质是同一坨活。
|
||||
|
||||
> 备注:React 是一次 agentic programming 试水。之前只手写过 Vue、没手写过 React,这一轮想全程靠 agent、尽量不读代码地把它做出来。OpenAPI 导出 → 生成类型化 TS client 作为 agent 的契约护栏,正好服务这个目标。
|
||||
|
||||
### 范围
|
||||
|
||||
- **React SPA**,FastAPI 挂载打包后的静态产物(同源,省掉 CORS)。
|
||||
- **Config 界面**:取代现有 Jinja config 页。
|
||||
- **数据可视化**:热力图、地图等,接管原先 Grafana 干的事。
|
||||
- **按需展示 DB 数据**(例如 poo 记录)。
|
||||
- **记录的小幅增删改**:用于修正不准确的记录。
|
||||
|
||||
### 后端配套
|
||||
|
||||
- **补一套 JSON API**:SPA 是客户端渲染,需要后端提供 config 读写、数据查询、记录 CRUD 等 JSON 端点。(同源不等于不需要 API——API 是“客户端怎么拿数据”,与文件托管在哪无关。)
|
||||
- **鉴权**:浏览器面向的新端点(含记录 CRUD)复用现有 session cookie 保护。
|
||||
- **类型化 client**:用 `scripts/export_openapi.py` 的输出生成 TS client。
|
||||
|
||||
### 鉴权边界(与 M3 衔接)
|
||||
|
||||
- 现在那个”裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
|
||||
- M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。
|
||||
|
||||
> **M2 已完成**(M2-T01 至 M2-T13 全部 done)。Jinja 模板已移除,React SPA 同源托管,多阶段 Docker 构建通过,所有校验闸门绿。
|
||||
|
||||
---
|
||||
|
||||
## M3 — 开放与移动端(远期试水)
|
||||
|
||||
### 目标
|
||||
|
||||
引入 token 鉴权并做一个 React Native 移动端。**明确是很远期、低投入的试水**——先把 React 前端做出来,之后才会碰移动端,且主要是想试试没做过的 React / React Native。
|
||||
|
||||
### 范围
|
||||
|
||||
- **OAuth-lite token 签发**:移动端在内置浏览器里用账号密码登录,走一遍类 OAuth 流程,服务端签发一个 bearer token 给 app 存起来使用。(本质是没有第三方的 Authorization Code 简化版。)
|
||||
- **React Native 移动端**:试水性质。
|
||||
- **给 ingestion 端点上 token**:把 M2 暂时维持裸奔的设备端点收口到 token 鉴权下。
|
||||
|
||||
### 为什么放最后
|
||||
|
||||
- 移动端是这一阶段最远期、最不确定的部分。
|
||||
- token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。
|
||||
|
||||
## Future Ideas(暂不排期,想到先记下)
|
||||
|
||||
> 这里收集**还没排进里程碑**的想法。不是承诺、也没有先后顺序;想做时再从这里捞出来细化成 `docs/design/` 的任务卡。**明确不开 M2.5**——下列条目一律先躺在 Future Ideas,之后再说。
|
||||
|
||||
### TOTP 二次验证(Dashboard 加固)
|
||||
|
||||
**动机**:M2 之后多了一个 Web Dashboard。它虽有单 admin 密码保护,但**大概率会暴露在公网**上,只靠密码这一层不够。给登录再叠一层 **TOTP(基于时间的一次性密码,RFC 6238)** 作为第二因子,做纵深防御。
|
||||
|
||||
**范围(粗略,待细化)**:
|
||||
|
||||
- 在现有单 admin(Argon2 + server-side session)登录之上,叠加 TOTP 第二步:密码校验通过后再验 6 位动态码,通过才发 session cookie。
|
||||
- 首次启用时生成 TOTP secret,给出可导入 Authenticator 的二维码 / 可手输密钥;同时生成一组一次性**恢复码(recovery codes)**。
|
||||
|
||||
**运维 / 命令行要求(关键,实现时必须满足)**:
|
||||
|
||||
1. **忘记密码**:不需要任何 Web 端“找回密码”流程——直接在命令行里重置 admin 密码即可(沿用现有 CLI 思路)。
|
||||
2. **TOTP 重置 / 恢复**:必须提供**命令行重置入口**。要覆盖最坏情况——**连恢复码(restore key)都丢了**,也能纯靠 CLI 把 TOTP 关掉 / 重新发放新的 secret,从而恢复登录。即:**CLI 是不依赖任何已存恢复凭据的最终逃生通道**,不能出现“密钥丢了就彻底锁死”的死角。
|
||||
|
||||
**先不做**:本条仅记入 Future Ideas,不进 M2.5、不排期;之后再细化为 design 任务卡。
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,209 @@
|
||||
# Home Automation — Frontend
|
||||
|
||||
React SPA for the home-automation backend. Built with Vite + React 18 + TypeScript.
|
||||
Scaffolded in M2-T06; feature pages filled in by T07–T10.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Library | Version |
|
||||
|---|---|---|
|
||||
| Build | Vite | 6.x |
|
||||
| UI framework | React | 18.x |
|
||||
| Language | TypeScript | 5.x |
|
||||
| Component library | Mantine | 7.x |
|
||||
| Data fetching | TanStack Query | 5.x |
|
||||
| Routing | react-router-dom | 6.x |
|
||||
| API client codegen | openapi-typescript | 7.x |
|
||||
| API client runtime | openapi-fetch | 0.17.x |
|
||||
| Testing | Vitest + @testing-library/react | 4.x / 14.x |
|
||||
|
||||
## npm Scripts
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm run dev` | Start Vite dev server (with backend proxy — see below) |
|
||||
| `npm run build` | `tsc -b && vite build` — type-check then build to `dist/` |
|
||||
| `npm run preview` | Serve the built `dist/` locally |
|
||||
| `npm run lint` | ESLint (flat config, React + TypeScript rules) |
|
||||
| `npm run typecheck` | `tsc --noEmit` — type-check without emitting files |
|
||||
| `npm run test` | Vitest (run once, no watch) |
|
||||
| `npm run codegen` | Regenerate `src/api/schema.d.ts` from `../openapi/openapi.json` |
|
||||
|
||||
All frontend gates must pass before any task is considered done:
|
||||
```bash
|
||||
npm run codegen
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm run test
|
||||
npm run build # must produce dist/
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── index.html Vite entry HTML
|
||||
├── vite.config.ts Vite + Vitest config; dev proxy
|
||||
├── tsconfig.json References tsconfig.app.json + tsconfig.node.json
|
||||
├── tsconfig.app.json App source TS config (strict, react-jsx)
|
||||
├── tsconfig.node.json Vite config TS config
|
||||
├── eslint.config.js Flat ESLint config (React + TypeScript rules)
|
||||
├── package.json Dependencies + npm scripts
|
||||
├── package-lock.json Lockfile (committed; CI uses npm ci)
|
||||
└── src/
|
||||
├── main.tsx Entry point; mounts <App> into #root
|
||||
├── App.tsx Provider stack + route tree (MantineProvider → QueryClient → Router → SessionProvider)
|
||||
├── vite-env.d.ts /// <reference types="vite/client" /> for CSS imports
|
||||
├── test-setup.ts Vitest global setup (@testing-library/jest-dom)
|
||||
├── api/
|
||||
│ ├── schema.d.ts AUTO-GENERATED from openapi/openapi.json (committed)
|
||||
│ ├── client.ts openapi-fetch client + CSRF/cookie/401 middleware
|
||||
│ └── csrf.ts Module-level CSRF token holder (setCsrfToken / getCsrfToken)
|
||||
├── auth/
|
||||
│ ├── SessionProvider.tsx TanStack Query against GET /api/session; exposes useSession()
|
||||
│ └── ProtectedRoute.tsx Redirects to /login when unauthenticated
|
||||
└── pages/
|
||||
├── LoginPage.tsx Placeholder → T07 builds the real form
|
||||
├── HomePage.tsx Placeholder → T09 builds the map/heatmap view
|
||||
└── ConfigPage.tsx Placeholder → T08 builds the config editor
|
||||
```
|
||||
|
||||
## Dev Proxy (local development)
|
||||
|
||||
`npm run dev` starts Vite on port 5173. The Vite config proxies API/auth paths
|
||||
to the FastAPI backend running on port 8000:
|
||||
|
||||
| Proxied path | Backend URL |
|
||||
|---|---|
|
||||
| `/api/*` | `http://localhost:8000` |
|
||||
| `/login` | `http://localhost:8000` |
|
||||
| `/logout` | `http://localhost:8000` |
|
||||
| `/static/*` | `http://localhost:8000` |
|
||||
| `/docs` | `http://localhost:8000` |
|
||||
| `/openapi.json` | `http://localhost:8000` |
|
||||
|
||||
To develop locally:
|
||||
1. Start the backend: `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000`
|
||||
2. Start the frontend: `cd frontend && npm run dev`
|
||||
3. Open `http://localhost:5173` — the app proxies all API calls to the backend.
|
||||
|
||||
Since the dev server proxies the session cookie path, auth flows work exactly as
|
||||
they would in the deployed (same-origin) setup.
|
||||
|
||||
## Adding a New Page + Typed Query
|
||||
|
||||
This is the pattern every task T07–T10 follows to wire up a real page:
|
||||
|
||||
### 1. Run codegen (if the OpenAPI contract changed)
|
||||
|
||||
```bash
|
||||
npm run codegen
|
||||
```
|
||||
|
||||
The generated `src/api/schema.d.ts` is committed to the repo. CI enforces that
|
||||
the file is in sync with `openapi/openapi.json` via:
|
||||
```bash
|
||||
npm run codegen && git diff --exit-code frontend/src/api/schema.d.ts
|
||||
```
|
||||
|
||||
### 2. Import the typed client
|
||||
|
||||
```typescript
|
||||
// src/pages/SomePage.tsx
|
||||
import apiClient from '../api/client'
|
||||
```
|
||||
|
||||
### 3. Write a typed TanStack Query
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
function usePooRecords(limit = 100) {
|
||||
return useQuery({
|
||||
queryKey: ['poo', { limit }],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/poo', { params: { query: { limit } } })
|
||||
// res.data is typed as PooResponse | undefined
|
||||
// On non-2xx the middleware throws ApiError; TanStack Query catches it.
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The `params.query` and `params.path` objects are fully typed from `schema.d.ts`.
|
||||
TypeScript will error if you pass unknown query params or mistype a path param.
|
||||
|
||||
### 4. Write a typed mutation (write request)
|
||||
|
||||
```typescript
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
function useDeletePoo() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (timestamp: string) =>
|
||||
apiClient.DELETE('/api/poo/{timestamp}', {
|
||||
params: { path: { timestamp } },
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['poo'] }),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The middleware (`src/api/client.ts`) automatically injects the `X-CSRF-Token` header
|
||||
on all non-GET/HEAD requests (sourced from `getCsrfToken()`). You do not need to
|
||||
handle CSRF manually in page code.
|
||||
|
||||
### 5. Add the route in App.tsx
|
||||
|
||||
```typescript
|
||||
// App.tsx
|
||||
import { SomePage } from './pages/SomePage'
|
||||
|
||||
// Inside <Routes>:
|
||||
<Route path="/some-path" element={<SomePage />} />
|
||||
// or, if protected:
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/some-path" element={<SomePage />} />
|
||||
</Route>
|
||||
```
|
||||
|
||||
## OpenAPI codegen + CI sync rule
|
||||
|
||||
`src/api/schema.d.ts` is committed to the repository (not gitignored).
|
||||
|
||||
**Rule**: whenever `openapi/openapi.json` changes (any backend task that modifies
|
||||
a route or schema), CI must run:
|
||||
```bash
|
||||
cd frontend && npm run codegen
|
||||
git diff --exit-code frontend/src/api/schema.d.ts
|
||||
```
|
||||
If the file has changed but the new version was not committed, CI fails.
|
||||
|
||||
To update manually after a backend change:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run codegen
|
||||
git add src/api/schema.d.ts
|
||||
git commit -m "M2-Txx: update generated OpenAPI types"
|
||||
```
|
||||
|
||||
## Production Build
|
||||
|
||||
The production build (`npm run build`) writes static files to `frontend/dist/`.
|
||||
In the deployed setup (M2-T11 onwards), FastAPI serves `dist/` as a static
|
||||
directory and falls back to `dist/index.html` for all non-`/api` paths,
|
||||
enabling client-side routing with deep links.
|
||||
|
||||
The multi-stage Dockerfile (M2-T12) builds the frontend in a Node container and
|
||||
copies only `dist/` into the Python image — the production image does not
|
||||
contain Node or npm.
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'src/api/schema.d.ts'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
react: reactPlugin,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Home Automation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7289
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "home-automation-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"codegen": "openapi-typescript ../openapi/openapi.json -o ./src/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^7.17.8",
|
||||
"@mantine/hooks": "^7.17.8",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/leaflet.markercluster": "^1.5.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet.heat": "^0.2.0",
|
||||
"leaflet.markercluster": "^1.5.3",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^6.4.3",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* App — top-level provider stack and route tree.
|
||||
*
|
||||
* Provider order (outermost first):
|
||||
* MantineProvider → QueryClientProvider → BrowserRouter → SessionProvider → routes
|
||||
*
|
||||
* Route tree:
|
||||
* /login → LoginPage (public)
|
||||
* /change-password → ProtectedRoute → ChangePasswordPage (T07: forced password change gate)
|
||||
* / → ProtectedRoute → AppLayout → HomePage (T09)
|
||||
* /config → ProtectedRoute → AppLayout → ConfigPage (T08)
|
||||
*
|
||||
* AppLayout renders a nav with a gear-icon entry for /config and a logout button (T07).
|
||||
*/
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Routes, Route, Link, Outlet, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MantineProvider,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
useMantineColorScheme,
|
||||
useComputedColorScheme,
|
||||
} from '@mantine/core'
|
||||
import { List, Settings, Sun, Moon, LogOut } from 'react-feather'
|
||||
|
||||
// Mantine requires its CSS to be imported once.
|
||||
import '@mantine/core/styles.css'
|
||||
|
||||
import { SessionProvider } from './auth/SessionProvider'
|
||||
import { ProtectedRoute } from './auth/ProtectedRoute'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { ConfigPage } from './pages/ConfigPage'
|
||||
import { RecordsPage } from './pages/RecordsPage'
|
||||
import { ChangePasswordPage } from './pages/ChangePasswordPage'
|
||||
import apiClient from './api/client'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TanStack Query client (singleton, created outside render to avoid re-creation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't retry on 4xx — we handle 401 in the middleware
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof Error && 'status' in error) {
|
||||
const status = (error as unknown as { status: number }).status
|
||||
if (status >= 400 && status < 500) return false
|
||||
}
|
||||
return failureCount < 2
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logout button component (needs navigate + queryClient hooks, so it's a component)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LogoutButton() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await apiClient.POST('/api/auth/logout')
|
||||
} catch {
|
||||
// Ignore errors on logout — we clear the session regardless.
|
||||
}
|
||||
// Invalidate session so SessionProvider becomes unauthenticated.
|
||||
await qc.invalidateQueries({ queryKey: ['session'] })
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label="Log out">
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
onClick={handleLogout}
|
||||
aria-label="Log out"
|
||||
data-testid="logout-button"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dark-mode toggle (sits next to the gear / settings icon)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ColorSchemeToggle() {
|
||||
const { setColorScheme } = useMantineColorScheme()
|
||||
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||
const isDark = computed === 'dark'
|
||||
return (
|
||||
<Tooltip label={isDark ? 'Light mode' : 'Dark mode'}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Toggle color scheme"
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
data-testid="color-scheme-toggle"
|
||||
>
|
||||
{isDark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// App shell layout (used by all protected pages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AppLayout() {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Top nav */}
|
||||
<nav
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0.5rem 1rem',
|
||||
borderBottom: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ fontWeight: 600, textDecoration: 'none' }}>
|
||||
Home Automation
|
||||
</Link>
|
||||
|
||||
<Group gap="xs">
|
||||
{/* Records nav link */}
|
||||
<Tooltip label="Records">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/records"
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Records"
|
||||
>
|
||||
<List size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* Dark-mode toggle — directly beside the settings gear */}
|
||||
<ColorSchemeToggle />
|
||||
{/* Settings — links to config page (§5#10) */}
|
||||
<Tooltip label="Settings">
|
||||
<ActionIcon
|
||||
component={Link}
|
||||
to="/config"
|
||||
variant="default"
|
||||
size="lg"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<LogoutButton />
|
||||
</Group>
|
||||
</nav>
|
||||
|
||||
{/* Page content */}
|
||||
<main style={{ flex: 1 }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root app
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="auto">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<SessionProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Forced password change — protected (must be logged in) but outside AppLayout */}
|
||||
<Route
|
||||
path="/change-password"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ChangePasswordPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes — all nested under AppLayout */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/records" element={<RecordsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</SessionProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* csrfMiddleware 401-handling regression tests.
|
||||
*
|
||||
* Bug: clicking Logout (or landing on /login) flooded GET /api/session with 401s
|
||||
* and the page hung instead of returning to the login screen.
|
||||
*
|
||||
* Root cause: the middleware redirected on EVERY 401, including the session
|
||||
* probe's own 401. The redirect invalidated the ['session'] query, which
|
||||
* refetched GET /api/session, which 401'd, which redirected again → an infinite
|
||||
* refetch loop. These tests pin the fix: the session probe and the login
|
||||
* endpoint own their 401s (no redirect); any other endpoint's 401 still
|
||||
* redirects (session expired mid-use).
|
||||
*
|
||||
* We call onResponse() directly (rather than going through apiClient.GET) so the
|
||||
* test exercises the exact 401 branch without the singleton's relative baseUrl,
|
||||
* which has no absolute origin to resolve against under jsdom.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { Middleware } from 'openapi-fetch'
|
||||
import { csrfMiddleware, registerLoginRedirect } from './client'
|
||||
|
||||
type OnResponse = NonNullable<Middleware['onResponse']>
|
||||
type OnResponseParams = Parameters<OnResponse>[0]
|
||||
|
||||
/** Build the minimal onResponse params for the given schema path + response. */
|
||||
function params(schemaPath: string, response: Response): OnResponseParams {
|
||||
return { schemaPath, response, request: new Request('http://test.local' + schemaPath) } as OnResponseParams
|
||||
}
|
||||
|
||||
function response401(): Response {
|
||||
return new Response(JSON.stringify({ detail: 'unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const onResponse = csrfMiddleware.onResponse as OnResponse
|
||||
|
||||
describe('csrfMiddleware 401 redirect (session-flood regression)', () => {
|
||||
const redirect = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
redirect.mockReset()
|
||||
registerLoginRedirect(redirect)
|
||||
})
|
||||
|
||||
it('does NOT redirect when GET /api/session returns 401 (probe owns its 401)', async () => {
|
||||
await onResponse(params('/api/session', response401()))
|
||||
expect(redirect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT redirect when POST /api/auth/login returns 401 (bad credentials)', async () => {
|
||||
await onResponse(params('/api/auth/login', response401()))
|
||||
expect(redirect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects when a normal endpoint returns 401 (session expired mid-use)', async () => {
|
||||
await onResponse(params('/api/locations', response401()))
|
||||
expect(redirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Typed API client built on openapi-fetch + generated schema.d.ts.
|
||||
*
|
||||
* Middleware contract (orchestrator-decisions.md §11):
|
||||
* 1. Always send cookies (credentials: "include"; same-origin auto-sends but explicit is clear).
|
||||
* 2. Non-GET/HEAD requests inject X-CSRF-Token from the csrf holder.
|
||||
* Exception: POST /api/auth/login skips injection (unauthenticated endpoint).
|
||||
* 3. 401 responses → clear session state + navigate to /login.
|
||||
* 4. Other non-2xx responses → throw an ApiError carrying the parsed JSON body,
|
||||
* so callers (e.g. SMTP test) can inspect body.result.
|
||||
*/
|
||||
|
||||
import createClient, { type Middleware } from 'openapi-fetch'
|
||||
import type { paths } from './schema.d.ts'
|
||||
import { getCsrfToken } from './csrf'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Error thrown for non-2xx, non-401 responses. Carries the parsed JSON body. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
public readonly body: any,
|
||||
) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal navigation helper (avoids React-router import at module level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _navigateToLogin: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Register a callback that the middleware calls on 401.
|
||||
* SessionProvider calls this during its setup.
|
||||
*/
|
||||
export function registerLoginRedirect(fn: () => void): void {
|
||||
_navigateToLogin = fn
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSRF middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
const LOGIN_PATH = '/api/auth/login'
|
||||
|
||||
/**
|
||||
* Endpoints where a 401 is an EXPECTED, locally-handled outcome and must NOT
|
||||
* trigger the global login redirect:
|
||||
* - GET /api/session — the session probe; 401 means "not logged in", handled
|
||||
* by SessionProvider's queryFn (returns null → unauthenticated state).
|
||||
* - POST /api/auth/login — bad-credentials check; 401 handled by LoginPage.
|
||||
*
|
||||
* Redirecting on these would invalidate the session query, which refetches
|
||||
* /api/session, which 401s, which redirects again → an infinite loop that
|
||||
* floods GET /api/session after logout and on the login page.
|
||||
*/
|
||||
const SESSION_PATH = '/api/session'
|
||||
const NO_REDIRECT_ON_401 = new Set<string>([SESSION_PATH, LOGIN_PATH])
|
||||
|
||||
export const csrfMiddleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
// Always include cookies (same-origin; explicit for clarity)
|
||||
// Note: credentials is set at client level; this is belt-and-suspenders doc.
|
||||
|
||||
const method = request.method.toUpperCase()
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (WRITE_METHODS.has(method) && url.pathname !== LOGIN_PATH) {
|
||||
const token = getCsrfToken()
|
||||
if (token) {
|
||||
request.headers.set('X-CSRF-Token', token)
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
},
|
||||
|
||||
async onResponse({ schemaPath, response }) {
|
||||
if (response.status === 401) {
|
||||
// The session probe and the login endpoint own their 401s (see
|
||||
// NO_REDIRECT_ON_401). For any OTHER endpoint, a 401 means the session
|
||||
// expired mid-use → redirect to /login. Crucially, NOT redirecting on the
|
||||
// session probe breaks the refetch→401→redirect→refetch flood loop.
|
||||
if (!NO_REDIRECT_ON_401.has(schemaPath) && _navigateToLogin) {
|
||||
_navigateToLogin()
|
||||
}
|
||||
// Return the original response so callers can handle 401 if needed.
|
||||
return response
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Parse body and throw; caller can catch ApiError and read .body
|
||||
let body: unknown
|
||||
try {
|
||||
body = await response.clone().json()
|
||||
} catch {
|
||||
body = null
|
||||
}
|
||||
throw new ApiError(response.status, body)
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client instance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const apiClient = createClient<paths>({
|
||||
baseUrl: '/',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
apiClient.use(csrfMiddleware)
|
||||
|
||||
export default apiClient
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Smoke tests for the CSRF token holder.
|
||||
* These run in isolation (no DOM, no React) and validate the module contract.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { setCsrfToken, getCsrfToken } from './csrf'
|
||||
|
||||
describe('csrf holder', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to empty between tests by setting empty string
|
||||
setCsrfToken('')
|
||||
})
|
||||
|
||||
it('returns empty string before any token is set', () => {
|
||||
expect(getCsrfToken()).toBe('')
|
||||
})
|
||||
|
||||
it('stores and returns the token that was set', () => {
|
||||
setCsrfToken('test-token-abc123')
|
||||
expect(getCsrfToken()).toBe('test-token-abc123')
|
||||
})
|
||||
|
||||
it('overwrites a previously set token', () => {
|
||||
setCsrfToken('first')
|
||||
setCsrfToken('second')
|
||||
expect(getCsrfToken()).toBe('second')
|
||||
})
|
||||
|
||||
it('can be reset to empty', () => {
|
||||
setCsrfToken('some-token')
|
||||
setCsrfToken('')
|
||||
expect(getCsrfToken()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Module-level CSRF token holder.
|
||||
*
|
||||
* The token is populated by SessionProvider after a successful GET /api/session.
|
||||
* The fetch client middleware reads it on every non-GET/HEAD request.
|
||||
*
|
||||
* Per the project CSRF contract (m2-frontend-v2.md §3.2, orchestrator-decisions.md §3):
|
||||
* - Server checks presence/non-empty only, does NOT validate the value.
|
||||
* - Sending an empty-string or stale value will result in a 403; callers must
|
||||
* ensure setCsrfToken() is called before issuing write requests.
|
||||
*/
|
||||
|
||||
let _csrfToken = ''
|
||||
|
||||
/** Store the CSRF token returned by GET /api/session. */
|
||||
export function setCsrfToken(token: string): void {
|
||||
_csrfToken = token
|
||||
}
|
||||
|
||||
/** Return the current CSRF token (may be empty string if not yet set). */
|
||||
export function getCsrfToken(): string {
|
||||
return _csrfToken
|
||||
}
|
||||
Vendored
+1286
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* ProtectedRoute — renders children when authenticated; redirects to /login otherwise.
|
||||
*
|
||||
* Additional gate (M2-T07):
|
||||
* - If the authenticated user has force_password_change === true, redirect to
|
||||
* /change-password instead of rendering children. This prevents access to any
|
||||
* protected page until the password is changed.
|
||||
* - Shows a loading spinner while the session is still resolving to avoid flash-of-login.
|
||||
* - On unauthenticated access, preserves the intended destination in location.state.from
|
||||
* so LoginPage can redirect back after login.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
import { Center, Loader } from '@mantine/core'
|
||||
import { useSession } from './SessionProvider'
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const { status, user } = useSession()
|
||||
const location = useLocation()
|
||||
|
||||
if (status === 'loading') {
|
||||
// Render a centred spinner while we check the session — avoids a flash to /login.
|
||||
return (
|
||||
<Center mih="100vh">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
// Preserve the intended destination so LoginPage can redirect back after login.
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
// Authenticated but forced to change password — gate all protected pages.
|
||||
if (user?.force_password_change && location.pathname !== '/change-password') {
|
||||
return <Navigate to="/change-password" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* SessionProvider — fetches GET /api/session once on mount via TanStack Query.
|
||||
*
|
||||
* Contract (orchestrator-decisions.md §4, §11):
|
||||
* - 200 → authenticated; calls setCsrfToken(data.csrf_token) so write requests work.
|
||||
* - 401 → unauthenticated (not an error toast; normal state before login).
|
||||
* - Exposes { user, status } to descendants via useSession().
|
||||
*
|
||||
* Also registers the 401 → /login redirect with the API client middleware.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import apiClient, { registerLoginRedirect } from '../api/client'
|
||||
import { setCsrfToken } from '../api/csrf'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SessionUser = components['schemas']['SessionUser']
|
||||
|
||||
type SessionStatus = 'loading' | 'authenticated' | 'unauthenticated'
|
||||
|
||||
interface SessionContextValue {
|
||||
user: SessionUser | null
|
||||
status: SessionStatus
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SessionContext = createContext<SessionContextValue>({
|
||||
user: null,
|
||||
status: 'loading',
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Access the current session from any descendant component. */
|
||||
export function useSession(): SessionContextValue {
|
||||
return useContext(SessionContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SessionProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SessionProvider({ children }: SessionProviderProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Register the 401 redirect callback with the API client once.
|
||||
useEffect(() => {
|
||||
registerLoginRedirect(() => {
|
||||
// Invalidate the session query so any subscriber re-fetches (→ unauthenticated).
|
||||
queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||
navigate('/login', { replace: true })
|
||||
})
|
||||
}, [navigate, queryClient])
|
||||
|
||||
const { data, status, error } = useQuery({
|
||||
queryKey: ['session'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/session')
|
||||
// openapi-fetch returns { data, error, response }.
|
||||
// On 401 the middleware already navigates; here data will be undefined.
|
||||
return res.data ?? null
|
||||
},
|
||||
// Don't treat 401 as a React Query "error" — it's a normal unauthenticated state.
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
|
||||
// When we get session data, store the CSRF token.
|
||||
useEffect(() => {
|
||||
if (data?.csrf_token) {
|
||||
setCsrfToken(data.csrf_token)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
let sessionStatus: SessionStatus
|
||||
if (status === 'pending') {
|
||||
sessionStatus = 'loading'
|
||||
} else if (status === 'error' || data === null || !data) {
|
||||
// 401 returns null from our queryFn; any actual network error → unauthenticated.
|
||||
sessionStatus = 'unauthenticated'
|
||||
// Suppress unused variable warning for error in non-401 cases
|
||||
void error
|
||||
} else {
|
||||
sessionStatus = 'authenticated'
|
||||
}
|
||||
|
||||
const value: SessionContextValue = {
|
||||
user: data?.user ?? null,
|
||||
status: sessionStatus,
|
||||
}
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Entry point — mounts the React app into #root.
|
||||
*/
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element #root not found in document')
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* HeatLayers regression test — post-walkthrough fix.
|
||||
*
|
||||
* Bug: the heat layer's `setLatLngs` was called BEFORE the layer was added to the
|
||||
* map. A leaflet.heat layer that is not on a map has a null `_map`, and
|
||||
* `setLatLngs -> redraw` dereferences `_map._animating`, throwing
|
||||
* "Cannot read properties of null (reading '_animating')" and white-screening
|
||||
* the whole SPA right after login.
|
||||
*
|
||||
* This test exercises the REAL HeatLayers code path (not a wholesale RecordsMap
|
||||
* mock) and asserts the layer is added to the map BEFORE setLatLngs is called.
|
||||
* Against the old code (setLatLngs first), the ordering assertion fails.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
|
||||
const { callLog, setLatLngsSpy, mapAddLayerSpy } = vi.hoisted(() => {
|
||||
const callLog: string[] = []
|
||||
const setLatLngsSpy = vi.fn((_pts: unknown) => {
|
||||
callLog.push('setLatLngs')
|
||||
})
|
||||
const mapAddLayerSpy = vi.fn((_layer: unknown) => {
|
||||
callLog.push('addLayer')
|
||||
})
|
||||
return { callLog, setLatLngsSpy, mapAddLayerSpy }
|
||||
})
|
||||
|
||||
// Mock leaflet. heatLayer returns a fake layer whose setLatLngs logs call order;
|
||||
// Icon/DivIcon/marker exist because RecordsMap.tsx runs icon setup at module load.
|
||||
vi.mock('leaflet', () => {
|
||||
class FakeIcon {
|
||||
constructor(_opts: unknown) {}
|
||||
static Default = { prototype: {}, mergeOptions: vi.fn() }
|
||||
}
|
||||
return {
|
||||
Icon: FakeIcon,
|
||||
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) {
|
||||
return {}
|
||||
}),
|
||||
heatLayer: vi.fn(() => ({ setLatLngs: setLatLngsSpy, setOptions: vi.fn(), addTo: vi.fn() })),
|
||||
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), addTo: vi.fn(), clearLayers: vi.fn() })),
|
||||
marker: vi.fn(() => ({ bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() })),
|
||||
default: {},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('leaflet.heat', () => ({}))
|
||||
vi.mock('leaflet.markercluster', () => ({}))
|
||||
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
|
||||
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
|
||||
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
|
||||
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
|
||||
|
||||
// useMap returns a fake map; hasLayer=false so addLayer is exercised.
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TileLayer: () => null,
|
||||
useMap: () => ({
|
||||
addLayer: mapAddLayerSpy,
|
||||
removeLayer: vi.fn(),
|
||||
hasLayer: () => false,
|
||||
getSize: () => ({ x: 800, y: 600 }),
|
||||
latLngToContainerPoint: () => ({ x: 100, y: 100 }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { HeatLayers } from './RecordsMap'
|
||||
import type { HeatPoint } from './mapUtils'
|
||||
|
||||
const heatPoints: HeatPoint[] = [
|
||||
[39.9, 116.4, 1],
|
||||
[39.91, 116.41, 1],
|
||||
]
|
||||
|
||||
describe('HeatLayers (real code path — regression for null _map crash)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
callLog.length = 0
|
||||
})
|
||||
|
||||
it('adds the heat layer to the map BEFORE calling setLatLngs', () => {
|
||||
render(
|
||||
<HeatLayers
|
||||
locationHeatPoints={heatPoints}
|
||||
pooHeatPoints={[]}
|
||||
showLocationHeat={true}
|
||||
showPooHeat={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Data was applied...
|
||||
expect(setLatLngsSpy).toHaveBeenCalledWith(heatPoints)
|
||||
// ...and the layer was added to the map first. The old buggy order
|
||||
// (setLatLngs before addLayer) makes this fail.
|
||||
expect(callLog).toEqual(['addLayer', 'setLatLngs'])
|
||||
expect(callLog.indexOf('addLayer')).toBeLessThan(callLog.indexOf('setLatLngs'))
|
||||
})
|
||||
|
||||
it('does not call setLatLngs while the layer is hidden (off the map)', () => {
|
||||
render(
|
||||
<HeatLayers
|
||||
locationHeatPoints={heatPoints}
|
||||
pooHeatPoints={heatPoints}
|
||||
showLocationHeat={false}
|
||||
showPooHeat={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Hidden layers are never on the map, so setLatLngs must not run on them.
|
||||
expect(setLatLngsSpy).not.toHaveBeenCalled()
|
||||
expect(mapAddLayerSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* ScatterLayer unit test — M2-T09 REWORK 1.
|
||||
*
|
||||
* This test exercises the REAL ScatterLayer code path (not a wholesale RecordsMap mock).
|
||||
* It verifies that ScatterLayer uses the imported leaflet namespace (L.markerClusterGroup)
|
||||
* rather than window.L / globalThis.L, which would silently fail in Vite ESM bundles.
|
||||
*
|
||||
* The test:
|
||||
* - mocks react-leaflet's useMap() to return a fake map object
|
||||
* - provides a mock markerClusterGroup spy via the leaflet module mock
|
||||
* - renders ScatterLayer with some points
|
||||
* - asserts that L.markerClusterGroup was called (i.e. the import path is used)
|
||||
* - asserts that addLayer was called for each point
|
||||
* - asserts that clicking a marker invokes onSelectLocation / onSelectPoo
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Use vi.hoisted() to define mocks that are referenced inside vi.mock factories.
|
||||
// vi.mock() factories are hoisted to the top of the file, so any variables they
|
||||
// reference must also be hoisted.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers } =
|
||||
vi.hoisted(() => {
|
||||
const clickHandlers: Array<() => void> = []
|
||||
const fakeAddLayer = vi.fn()
|
||||
const fakeCluster = {
|
||||
addLayer: fakeAddLayer,
|
||||
addTo: vi.fn(),
|
||||
clearLayers: vi.fn(),
|
||||
}
|
||||
const markerClusterGroupSpy = vi.fn(() => fakeCluster)
|
||||
const fakeMapAddLayer = vi.fn()
|
||||
return { markerClusterGroupSpy, fakeAddLayer, fakeMapAddLayer, markerClickHandlers: clickHandlers }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock leaflet BEFORE importing ScatterLayer.
|
||||
// We use the hoisted spy so vi.mock factory can reference it safely.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const markerClusterGroupSpy_ = markerClusterGroupSpy
|
||||
const markerClickHandlers_ = markerClickHandlers
|
||||
|
||||
// Icon must be a real constructor (used as `new Icon(...)`)
|
||||
class FakeIcon {
|
||||
constructor(_opts: unknown) {}
|
||||
static Default = { prototype: {}, mergeOptions: vi.fn() }
|
||||
}
|
||||
|
||||
return {
|
||||
Icon: FakeIcon,
|
||||
DivIcon: vi.fn(function FakeDivIcon(_opts: unknown) { return {} }),
|
||||
heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })),
|
||||
markerClusterGroup: markerClusterGroupSpy_,
|
||||
marker: vi.fn((_latlng: unknown, _opts: unknown) => {
|
||||
return {
|
||||
bindTooltip: vi.fn().mockReturnThis(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'click') {
|
||||
markerClickHandlers_.push(handler)
|
||||
}
|
||||
return { bindTooltip: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis() }
|
||||
}),
|
||||
}
|
||||
}),
|
||||
// `import * as L from 'leaflet'` in RecordsMap.tsx resolves to this module.
|
||||
// Vitest's module mock exposes all named exports as the namespace object,
|
||||
// so markerClusterGroup at the top level IS accessible as L.markerClusterGroup.
|
||||
default: {
|
||||
markerClusterGroup: markerClusterGroupSpy_,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('leaflet.heat', () => ({}))
|
||||
vi.mock('leaflet.markercluster', () => ({}))
|
||||
|
||||
// Mock image imports
|
||||
vi.mock('leaflet/dist/images/marker-icon-2x.png', () => ({ default: 'marker-icon-2x.png' }))
|
||||
vi.mock('leaflet/dist/images/marker-icon.png', () => ({ default: 'marker-icon.png' }))
|
||||
vi.mock('leaflet/dist/images/marker-shadow.png', () => ({ default: 'marker-shadow.png' }))
|
||||
|
||||
// Mock CSS imports
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
|
||||
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock react-leaflet: MapContainer renders children, useMap returns fake map.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="map-container">{children}</div>
|
||||
),
|
||||
TileLayer: () => null,
|
||||
useMap: () => ({
|
||||
addLayer: fakeMapAddLayer,
|
||||
removeLayer: vi.fn(),
|
||||
hasLayer: vi.fn(() => false),
|
||||
}),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import ScatterLayer AFTER mocks are set up.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { ScatterLayer } from './RecordsMap'
|
||||
import type { LocationMapPoint, PooMapPoint } from './mapUtils'
|
||||
import type { LocationRecord, PooRecord } from '../records'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locationRecord: LocationRecord = {
|
||||
person: 'alice',
|
||||
datetime: '2026-01-15T10:00:00Z',
|
||||
latitude: 39.9,
|
||||
longitude: 116.4,
|
||||
altitude: null,
|
||||
}
|
||||
const locationPoints: LocationMapPoint[] = [
|
||||
{ lat: 39.9, lng: 116.4, record: locationRecord },
|
||||
]
|
||||
|
||||
const pooRecord: PooRecord = {
|
||||
timestamp: '2026-01-20T09:00:00Z',
|
||||
status: 'done',
|
||||
latitude: 39.91,
|
||||
longitude: 116.41,
|
||||
}
|
||||
const pooPoints: PooMapPoint[] = [
|
||||
{ lat: 39.91, lng: 116.41, record: pooRecord },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ScatterLayer (real code path — not mocked RecordsMap)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
markerClickHandlers.length = 0
|
||||
})
|
||||
|
||||
it('calls L.markerClusterGroup (imported namespace) when showScatter=true', () => {
|
||||
render(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={[]}
|
||||
showScatter={true}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// KEY assertion: markerClusterGroup was called via the IMPORTED namespace.
|
||||
// With the old window.L / globalThis.L approach, this spy would never be
|
||||
// invoked because window.L is undefined in Vite ESM bundles.
|
||||
expect(markerClusterGroupSpy).toHaveBeenCalledOnce()
|
||||
expect(markerClusterGroupSpy).toHaveBeenCalledWith({
|
||||
maxClusterRadius: 50,
|
||||
showCoverageOnHover: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('calls cluster group addLayer for each location and poo scatter point', () => {
|
||||
render(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={pooPoints}
|
||||
showScatter={true}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// One addLayer call per point (1 location + 1 poo = 2).
|
||||
expect(fakeAddLayer).toHaveBeenCalledTimes(2)
|
||||
// The cluster group itself must be added to the map.
|
||||
const fakeCluster = markerClusterGroupSpy.mock.results[0]?.value
|
||||
expect(fakeMapAddLayer).toHaveBeenCalledWith(fakeCluster)
|
||||
})
|
||||
|
||||
it('does NOT create cluster group when showScatter=false', () => {
|
||||
render(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={pooPoints}
|
||||
showScatter={false}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(markerClusterGroupSpy).not.toHaveBeenCalled()
|
||||
expect(fakeAddLayer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes onSelectLocation when a location marker is clicked', () => {
|
||||
const onSelectLocation = vi.fn()
|
||||
|
||||
render(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={locationPoints}
|
||||
pooScatterPoints={[]}
|
||||
showScatter={true}
|
||||
onSelectLocation={onSelectLocation}
|
||||
onSelectPoo={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
// At least one marker click handler should have been registered.
|
||||
expect(markerClickHandlers.length).toBeGreaterThan(0)
|
||||
// Simulate click on the first (location) marker.
|
||||
markerClickHandlers[0]()
|
||||
expect(onSelectLocation).toHaveBeenCalledOnce()
|
||||
expect(onSelectLocation).toHaveBeenCalledWith(locationRecord)
|
||||
})
|
||||
|
||||
it('invokes onSelectPoo when a poo marker is clicked', () => {
|
||||
const onSelectPoo = vi.fn()
|
||||
|
||||
render(
|
||||
<ScatterLayer
|
||||
locationScatterPoints={[]}
|
||||
pooScatterPoints={pooPoints}
|
||||
showScatter={true}
|
||||
onSelectLocation={vi.fn()}
|
||||
onSelectPoo={onSelectPoo}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(markerClickHandlers.length).toBeGreaterThan(0)
|
||||
markerClickHandlers[0]()
|
||||
expect(onSelectPoo).toHaveBeenCalledOnce()
|
||||
expect(onSelectPoo).toHaveBeenCalledWith(pooRecord)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user