Compare commits
54 Commits
35aee79d93
..
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 |
@@ -8,3 +8,6 @@ data
|
|||||||
openapi
|
openapi
|
||||||
src
|
src
|
||||||
|
|
||||||
|
# Frontend host build artifacts — built inside the node stage, not needed from context
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ APP_NAME=Home Automation Backend (Python)
|
|||||||
APP_ENV=production
|
APP_ENV=production
|
||||||
APP_HOSTNAME=home-automation.example.com
|
APP_HOSTNAME=home-automation.example.com
|
||||||
APP_DATABASE_URL=sqlite:////app/data/app.db
|
APP_DATABASE_URL=sqlite:////app/data/app.db
|
||||||
LOCATION_DATABASE_URL=sqlite:////app/data/locationRecorder.db
|
|
||||||
POO_DATABASE_URL=sqlite:////app/data/pooRecorder.db
|
|
||||||
AUTH_BOOTSTRAP_USERNAME=admin
|
AUTH_BOOTSTRAP_USERNAME=admin
|
||||||
AUTH_BOOTSTRAP_PASSWORD=change-me
|
AUTH_BOOTSTRAP_PASSWORD=change-me
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,3 +5,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
data/
|
data/
|
||||||
|
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
|
||||||
|
```
|
||||||
+21
-4
@@ -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
|
FROM python:3.12-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
@@ -11,15 +28,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY alembic_app ./alembic_app
|
COPY alembic_app ./alembic_app
|
||||||
COPY alembic_app.ini ./
|
COPY alembic_app.ini ./
|
||||||
COPY alembic_location ./alembic_location
|
|
||||||
COPY alembic_location.ini ./
|
|
||||||
COPY alembic_poo ./alembic_poo
|
|
||||||
COPY alembic_poo.ini ./
|
|
||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
COPY docker ./docker
|
COPY docker ./docker
|
||||||
COPY README.md ./
|
COPY README.md ./
|
||||||
RUN mkdir -p /app/data
|
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
|
EXPOSE 8000
|
||||||
|
|
||||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
当前系统已经包含:
|
当前系统已经包含:
|
||||||
|
|
||||||
- FastAPI Web 应用与服务端模板页面
|
- FastAPI Web 应用(React SPA 前端 + JSON API)
|
||||||
- SQLite + SQLAlchemy + Alembic 的三库结构
|
- SQLite + SQLAlchemy + Alembic 的单库结构
|
||||||
- username/password + server-side session 鉴权
|
- username/password + server-side session 鉴权
|
||||||
- runtime config 页面与 app DB 持久化
|
- runtime config 页面与 app DB 持久化
|
||||||
|
- public IPv4 monitor、历史持久化与定时检查
|
||||||
|
- SMTP 配置、测试发信与 public IPv4 changed 邮件通知
|
||||||
- location recorder
|
- location recorder
|
||||||
- poo recorder
|
- poo recorder
|
||||||
- Home Assistant inbound / outbound integration
|
- Home Assistant inbound / outbound integration
|
||||||
@@ -21,43 +23,37 @@
|
|||||||
|
|
||||||
## 当前配置现实
|
## 当前配置现实
|
||||||
|
|
||||||
当前系统仍然是三个独立的 SQLite 数据库文件,而不是单一数据库:
|
当前系统使用单一 SQLite 数据库文件(`app.db`),所有数据表都在其中:
|
||||||
|
|
||||||
- `app` 级共享数据使用自己的 DB 文件
|
- auth(单个 admin 用户、server-side session)
|
||||||
- `location` 模块使用自己的 DB 文件
|
- runtime config 持久化(`app_config` 表)
|
||||||
- `poo` 模块使用自己的 DB 文件
|
- public IPv4 当前状态与变化历史
|
||||||
|
- location 记录(`location` 表)
|
||||||
|
- poo 记录(`poo_records` 表)
|
||||||
|
|
||||||
当前阶段明确不借这次重构把这些 DB 合并。配置层已经显式反映这一点:
|
配置层只保留一个数据库环境变量:
|
||||||
|
|
||||||
- `APP_DATABASE_URL`
|
- `APP_DATABASE_URL`
|
||||||
- `LOCATION_DATABASE_URL`
|
|
||||||
- `POO_DATABASE_URL`
|
|
||||||
|
|
||||||
目前 auth、`location` 和 `poo` 都已经接到各自独立的数据库文件。
|
`app.db` 不会在应用启动时自动创建,需要先运行:
|
||||||
|
|
||||||
其中 `app` 级共享 DB 当前主要用于:
|
```bash
|
||||||
|
python -m scripts.run_migrations
|
||||||
|
```
|
||||||
|
|
||||||
- 单个 admin 用户
|
该命令会通过 Alembic 将 `app.db` 初始化或升级到最新 head(含 `location` / `poo_records` 表)。
|
||||||
- server-side session
|
|
||||||
- runtime config 持久化
|
|
||||||
|
|
||||||
这部分现在也使用 Alembic 管理:
|
|
||||||
|
|
||||||
- `app db` 不会在应用启动时自动创建
|
|
||||||
- 需要先运行 `python scripts/app_db_adopt.py`
|
|
||||||
- 这个脚本会创建新 DB 并建好 schema
|
|
||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
主要目录如下:
|
主要目录如下:
|
||||||
|
|
||||||
- `app/`: FastAPI 应用代码
|
- `app/`: FastAPI 应用代码(包含 JSON API、业务服务、数据模型)
|
||||||
- `alembic_app/`: App DB 的 Alembic migration 环境
|
- `frontend/`: React SPA 前端(Vite + React + TypeScript + Mantine)
|
||||||
- `alembic_location/`: Location DB 的 Alembic migration 环境
|
- `alembic_app/`: App DB 的 Alembic migration 环境(同时管理 `location` / `poo_records` 表)
|
||||||
- `alembic_poo/`: Poo DB 的 Alembic migration 环境
|
|
||||||
- `tests/`: pytest 测试
|
- `tests/`: pytest 测试
|
||||||
- `docs/`: 当前系统说明文档
|
- `docs/`: 当前系统说明文档
|
||||||
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
- `scripts/`: 辅助脚本,例如 OpenAPI 导出
|
||||||
|
- `openapi/`: OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),纳入版本控制
|
||||||
|
|
||||||
## 依赖管理
|
## 依赖管理
|
||||||
|
|
||||||
@@ -107,9 +103,7 @@ cp .env.example .env
|
|||||||
3. 初始化数据库
|
3. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/app_db_adopt.py
|
python -m scripts.run_migrations
|
||||||
python scripts/location_db_adopt.py
|
|
||||||
python scripts/poo_db_adopt.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 启动服务
|
4. 启动服务
|
||||||
@@ -120,30 +114,80 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
启动后可访问:
|
启动后可访问:
|
||||||
|
|
||||||
- 应用首页:`http://localhost:8000/`
|
- 应用首页(React SPA):`http://localhost:8000/`
|
||||||
- 健康检查:`http://localhost:8000/status`
|
- 健康检查:`http://localhost:8000/status`
|
||||||
- Swagger UI:`http://localhost:8000/docs`
|
- Swagger UI:`http://localhost:8000/docs`
|
||||||
- ReDoc:`http://localhost:8000/redoc`
|
- ReDoc:`http://localhost:8000/redoc`
|
||||||
|
|
||||||
|
## 前端 v2(React SPA)
|
||||||
|
|
||||||
|
M2 用 React SPA 取代了原有 Jinja 服务端模板,由 FastAPI 同源托管(同一容器、同一 origin)。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
|
||||||
|
- **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
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # 启动 Vite dev server(默认 :5173),代理后端
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```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
|
## 数据库与 Alembic
|
||||||
|
|
||||||
当前默认使用 SQLite,并区分三个数据库文件:
|
当前使用单一 SQLite 数据库文件:
|
||||||
|
|
||||||
- App DB:`sqlite:///./data/app.db`
|
- App DB:`sqlite:///./data/app.db`
|
||||||
- Location DB:`sqlite:///./data/locationRecorder.db`
|
|
||||||
- Poo DB:`sqlite:///./data/pooRecorder.db`
|
|
||||||
- 数据目录:`./data/`
|
- 数据目录:`./data/`
|
||||||
|
|
||||||
初始化 migration 环境后,可继续添加模型并生成迁移:
|
所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均通过单一 Alembic 链管理:
|
||||||
|
|
||||||
当前 `app`、`location` 和 `poo` 都已经有各自独立的 Alembic 链路。
|
- Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
||||||
|
- 统一 migration job:`python -m scripts.run_migrations`
|
||||||
|
- App DB 接管 / 初始化:`python scripts/app_db_adopt.py`
|
||||||
|
|
||||||
- App Alembic 环境:`alembic_app.ini` + `alembic_app/`
|
历史 location / poo 数据(旧版本遗留的独立 DB 文件)已通过以下脚本一次性迁移至 `app.db`(幂等,不删除旧文件):
|
||||||
- Location Alembic 环境:`alembic_location.ini` + `alembic_location/`
|
|
||||||
- Poo Alembic 环境:`alembic_poo.ini` + `alembic_poo/`
|
```bash
|
||||||
- App DB 初始化:`python scripts/app_db_adopt.py`
|
python -m scripts.migrate_legacy_data
|
||||||
- Location DB 接管 / 初始化:`python scripts/location_db_adopt.py`
|
```
|
||||||
- Poo DB 接管 / 初始化:`python scripts/poo_db_adopt.py`
|
|
||||||
|
|
||||||
## 基础鉴权
|
## 基础鉴权
|
||||||
|
|
||||||
@@ -151,9 +195,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
- 认证模型:`username/password`
|
- 认证模型:`username/password`
|
||||||
- 会话模型:server-side session + cookie
|
- 会话模型:server-side session + cookie
|
||||||
- 当前主要受保护页面:`/config`
|
- 当前受保护入口:React SPA(`/` 等客户端路由)调用 `/api/*` JSON 端点
|
||||||
- 当前公开页面:`/login`
|
- 当前公开页面:`/login`(SPA 登录页)
|
||||||
- 当前公开 API:现有业务 API 暂未在这一轮统一收口到 auth 下
|
- 当前公开 API:裸 ingestion 端点(`/location/record`、`/poo/record` 等设备调用端点)暂未收口到 session 保护(M3 再做)
|
||||||
|
|
||||||
安全实现的当前边界:
|
安全实现的当前边界:
|
||||||
|
|
||||||
@@ -161,7 +205,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
- session cookie 使用 `HttpOnly`
|
- session cookie 使用 `HttpOnly`
|
||||||
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
|
- `Secure` 默认随 `APP_ENV` 切换:非 development 时默认开启
|
||||||
- `SameSite=Lax`
|
- `SameSite=Lax`
|
||||||
- 登录表单和登出表单都有基础 CSRF 防护
|
- 写请求(POST/PUT/PATCH/DELETE)需携带 `X-CSRF-Token` header(SameSite=Lax + 自定义 header 纵深防御,无需 per-session token 值比对)
|
||||||
|
|
||||||
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
|
首次启动时,如果 `APP_DATABASE_URL` 对应的 auth DB 里还没有用户,应用会使用:
|
||||||
|
|
||||||
@@ -175,12 +219,14 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
首次登录后会被要求立即修改密码。这个 bootstrap 只用于首个用户落库,不是后续的完整配置管理方案。
|
||||||
|
|
||||||
当前前端主要有两条页面路径:
|
React SPA 主要页面路由(客户端路由,均由 FastAPI fallback 到 `index.html`):
|
||||||
|
|
||||||
- `/login`
|
- `/login`:登录页
|
||||||
- `/config`
|
- `/`:首页(地图热力图主视图)
|
||||||
|
- `/config`:配置页(取代原 Jinja `/config`)
|
||||||
|
- `/records`:记录管理列表页
|
||||||
|
|
||||||
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后都使用相对路径跳转到 `/config`。
|
无论是本地 `host:port` 还是反向代理后的域名访问,登录成功后进入 SPA 首页(`/`)。
|
||||||
|
|
||||||
## Config 持久化
|
## Config 持久化
|
||||||
|
|
||||||
@@ -195,11 +241,84 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||||||
|
|
||||||
这意味着:
|
这意味着:
|
||||||
|
|
||||||
- location / poo / app DB 地址仍然属于 bootstrap 范畴
|
- app DB 地址(`APP_DATABASE_URL`)仍然属于 bootstrap 范畴
|
||||||
- 运行时可编辑配置主要通过 `app_config` 表持久化
|
- 运行时可编辑配置主要通过 `app_config` 表持久化
|
||||||
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
|
- token / secret 这类运行时必须可取回的配置,目前允许明文存储在 config 表中
|
||||||
- 登录密码仍然单独使用 Argon2 哈希,不走 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
|
## OpenAPI
|
||||||
|
|
||||||
可使用下面的脚本重新导出当前 API 定义:
|
可使用下面的脚本重新导出当前 API 定义:
|
||||||
@@ -219,18 +338,20 @@ python scripts/export_openapi.py
|
|||||||
|
|
||||||
当前 Compose 分成两层:
|
当前 Compose 分成两层:
|
||||||
|
|
||||||
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取
|
- `docker-compose.yml`:默认使用 registry image,适合部署 / 生产拉取(暴露 8881)
|
||||||
- `docker-compose.override.yml`:仅为本地开发追加 `build: .`
|
- `docker-compose.dev.yml`:本地开发显式叠加层——追加 `build: .`、独立 project /
|
||||||
|
容器名(`-dev` 后缀)、暴露 8001,并把 DB 指向挂载的 `./data` 副本,可与生产栈在同一台机器上并存
|
||||||
|
|
||||||
本地开发启动方式:
|
本地开发启动方式(显式叠加 dev 层):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d --build
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
上面的命令会自动叠加 `docker-compose.override.yml`,因此本地仍然会按当前工作目录重新 build。
|
dev 层刻意不沿用 `docker-compose.override.yml` 这种会被 `docker compose up` 自动叠加的文件名,
|
||||||
|
因此默认的 `docker compose up` 只用生产基础文件,不会把开发端口 / 配置误带到生产。
|
||||||
|
|
||||||
如果要按生产方式直接从 registry 拉取并启动,显式只使用基础 compose 文件:
|
如果要按生产方式直接从 registry 拉取并启动,使用基础 compose 文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.yml pull
|
docker compose -f docker-compose.yml pull
|
||||||
@@ -287,9 +408,16 @@ pytest
|
|||||||
|
|
||||||
当前测试包含:
|
当前测试包含:
|
||||||
|
|
||||||
- app 基本启动测试
|
- app 启动与 `/status` 检查
|
||||||
- `/status` endpoint 测试
|
- 登录 / session / 鉴权流程
|
||||||
- 登录 / session 基础流程测试
|
- runtime config 读写
|
||||||
|
- public IPv4 monitor
|
||||||
|
- SMTP 配置与测试发信
|
||||||
|
- location / poo recorder 端点
|
||||||
|
- Home Assistant inbound 集成
|
||||||
|
- TickTick OAuth
|
||||||
|
- 部署与迁移(`run_migrations`)
|
||||||
|
- legacy 数据迁移脚本(`migrate_legacy_data`)
|
||||||
|
|
||||||
## OpenAPI 导出
|
## OpenAPI 导出
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -3,10 +3,13 @@ from logging.config import fileConfig
|
|||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy import engine_from_config, pool
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from app.auth_db import AuthBase
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.db import Base
|
||||||
from app.models.config import AppConfigEntry # noqa: F401
|
from app.models.config import AppConfigEntry # noqa: F401
|
||||||
from app.models.auth import AuthSession, AuthUser # 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
|
config = context.config
|
||||||
|
|
||||||
@@ -18,7 +21,7 @@ configured_url = config.get_main_option("sqlalchemy.url")
|
|||||||
if not configured_url or configured_url == "sqlite:///./data/app.db":
|
if not configured_url or configured_url == "sqlite:///./data/app.db":
|
||||||
config.set_main_option("sqlalchemy.url", settings.app_database_url)
|
config.set_main_option("sqlalchemy.url", settings.app_database_url)
|
||||||
|
|
||||||
target_metadata = AuthBase.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
def run_migrations_offline() -> None:
|
||||||
|
|||||||
@@ -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,37 +0,0 @@
|
|||||||
[alembic]
|
|
||||||
script_location = alembic_location
|
|
||||||
prepend_sys_path = .
|
|
||||||
path_separator = os
|
|
||||||
sqlalchemy.url = sqlite:///./data/locationRecorder.db
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
This directory contains the Alembic migration environment for the Python rewrite skeleton.
|
|
||||||
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models import Location # noqa: F401
|
|
||||||
from app.models.base import Base
|
|
||||||
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
configured_url = config.get_main_option("sqlalchemy.url")
|
|
||||||
if not configured_url or configured_url == "sqlite:///./data/locationRecorder.db":
|
|
||||||
config.set_main_option("sqlalchemy.url", settings.location_database_url)
|
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
|
||||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
|
|
||||||
@@ -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,37 +0,0 @@
|
|||||||
[alembic]
|
|
||||||
script_location = alembic_poo
|
|
||||||
prepend_sys_path = .
|
|
||||||
path_separator = os
|
|
||||||
sqlalchemy.url = sqlite:///./data/pooRecorder.db
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers = console
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy import engine_from_config, pool
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
from app.models.poo import PooRecord # noqa: F401
|
|
||||||
from app.poo_db import PooBase
|
|
||||||
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
configured_url = config.get_main_option("sqlalchemy.url")
|
|
||||||
if not configured_url or configured_url == "sqlite:///./data/pooRecorder.db":
|
|
||||||
config.set_main_option("sqlalchemy.url", settings.poo_database_url)
|
|
||||||
|
|
||||||
target_metadata = PooBase.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
url = config.get_main_option("sqlalchemy.url")
|
|
||||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
connectable = engine_from_config(
|
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"""poo baseline
|
|
||||||
|
|
||||||
Revision ID: 20260420_01_poo_baseline
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-04-20 00:00:00.000000
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
revision: str = "20260420_01_poo_baseline"
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"poo_records",
|
|
||||||
sa.Column("timestamp", sa.Text(), nullable=False),
|
|
||||||
sa.Column("status", sa.Text(), nullable=False),
|
|
||||||
sa.Column("latitude", sa.Float(), nullable=False),
|
|
||||||
sa.Column("longitude", sa.Float(), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint("timestamp"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("poo_records")
|
|
||||||
@@ -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)
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, Request, status
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from app.config import Settings
|
|
||||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
|
||||||
from app.services.auth import (
|
|
||||||
AuthenticatedSession,
|
|
||||||
authenticate_user,
|
|
||||||
change_password,
|
|
||||||
create_session,
|
|
||||||
AuthPasswordChangeError,
|
|
||||||
issue_login_csrf_token,
|
|
||||||
revoke_session,
|
|
||||||
validate_csrf_token,
|
|
||||||
)
|
|
||||||
from app.services.config_page import build_config_sections, is_ticktick_oauth_ready
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
|
||||||
router = APIRouter(tags=["auth"])
|
|
||||||
|
|
||||||
LOGIN_CSRF_COOKIE_NAME = "login_csrf"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
|
||||||
def login_page(
|
|
||||||
request: Request,
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> Response:
|
|
||||||
if current_auth is not None:
|
|
||||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
csrf_token = issue_login_csrf_token()
|
|
||||||
response = templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"app_name": settings.app_name,
|
|
||||||
"app_env": settings.app_env,
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"error_message": None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_class=HTMLResponse)
|
|
||||||
def login_submit(
|
|
||||||
request: Request,
|
|
||||||
username: str = Form(),
|
|
||||||
password: str = Form(),
|
|
||||||
csrf_token: str = Form(),
|
|
||||||
session: Session = Depends(get_auth_db),
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
|
||||||
) -> Response:
|
|
||||||
cookie_csrf_token = request.cookies.get(LOGIN_CSRF_COOKIE_NAME)
|
|
||||||
if not validate_csrf_token(expected=cookie_csrf_token, actual=csrf_token):
|
|
||||||
logger.warning("Rejected login attempt due to CSRF validation failure")
|
|
||||||
return _render_login_error(
|
|
||||||
request,
|
|
||||||
settings=settings,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
error_message="invalid login request",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = authenticate_user(session, username=username, password=password)
|
|
||||||
if user is None:
|
|
||||||
return _render_login_error(
|
|
||||||
request,
|
|
||||||
settings=settings,
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
error_message="invalid username or password",
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_session, raw_token = create_session(session, user=user, settings=settings)
|
|
||||||
response = RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.delete_cookie(LOGIN_CSRF_COOKIE_NAME, path="/login")
|
|
||||||
response.set_cookie(
|
|
||||||
key=settings.auth_session_cookie_name,
|
|
||||||
value=raw_token,
|
|
||||||
max_age=settings.auth_session_ttl_hours * 3600,
|
|
||||||
httponly=True,
|
|
||||||
secure=settings.auth_cookie_secure,
|
|
||||||
samesite="lax",
|
|
||||||
path="/",
|
|
||||||
)
|
|
||||||
logger.info("Created authenticated session for user '%s'", user.username)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config/change-password", response_class=HTMLResponse)
|
|
||||||
def change_password_submit(
|
|
||||||
request: Request,
|
|
||||||
current_password: str = Form(),
|
|
||||||
new_password: str = Form(),
|
|
||||||
confirm_password: str = Form(),
|
|
||||||
csrf_token: str = Form(),
|
|
||||||
session: Session = Depends(get_auth_db),
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> Response:
|
|
||||||
if current_auth is None:
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
if not validate_csrf_token(expected=current_auth.session.csrf_token, actual=csrf_token):
|
|
||||||
logger.warning("Rejected password change attempt due to CSRF validation failure")
|
|
||||||
return _render_config_page(
|
|
||||||
request,
|
|
||||||
settings=settings,
|
|
||||||
auth_db_session=session,
|
|
||||||
current_auth=current_auth,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
password_change_error="invalid password change request",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
change_password(
|
|
||||||
session,
|
|
||||||
user=current_auth.user,
|
|
||||||
current_password=current_password,
|
|
||||||
new_password=new_password,
|
|
||||||
confirm_password=confirm_password,
|
|
||||||
)
|
|
||||||
except AuthPasswordChangeError as exc:
|
|
||||||
logger.info(
|
|
||||||
"Rejected password change for user '%s': %s",
|
|
||||||
current_auth.user.username,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return _render_config_page(
|
|
||||||
request,
|
|
||||||
settings=settings,
|
|
||||||
auth_db_session=session,
|
|
||||||
current_auth=current_auth,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
password_change_error="password change failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Password updated for user '%s'", current_auth.user.username)
|
|
||||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/logout")
|
|
||||||
def logout(
|
|
||||||
request: Request,
|
|
||||||
csrf_token: str = Form(),
|
|
||||||
session: Session = Depends(get_auth_db),
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
if current_auth is not None and validate_csrf_token(
|
|
||||||
expected=current_auth.session.csrf_token, actual=csrf_token
|
|
||||||
):
|
|
||||||
revoke_session(session, auth_session=current_auth.session)
|
|
||||||
logger.info("Revoked authenticated session for user '%s'", current_auth.user.username)
|
|
||||||
else:
|
|
||||||
logger.warning("Rejected logout request due to missing session or invalid CSRF token")
|
|
||||||
|
|
||||||
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
response.delete_cookie(settings.auth_session_cookie_name, path="/")
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _render_login_error(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
settings: Settings,
|
|
||||||
status_code: int,
|
|
||||||
error_message: str,
|
|
||||||
) -> HTMLResponse:
|
|
||||||
csrf_token = issue_login_csrf_token()
|
|
||||||
response = templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"login.html",
|
|
||||||
{
|
|
||||||
"app_name": settings.app_name,
|
|
||||||
"app_env": settings.app_env,
|
|
||||||
"csrf_token": csrf_token,
|
|
||||||
"error_message": error_message,
|
|
||||||
},
|
|
||||||
status_code=status_code,
|
|
||||||
)
|
|
||||||
_set_login_csrf_cookie(response, settings=settings, token=csrf_token)
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _set_login_csrf_cookie(response: HTMLResponse, *, settings: Settings, token: str) -> None:
|
|
||||||
response.set_cookie(
|
|
||||||
key=LOGIN_CSRF_COOKIE_NAME,
|
|
||||||
value=token,
|
|
||||||
max_age=1800,
|
|
||||||
httponly=True,
|
|
||||||
secure=settings.auth_cookie_secure,
|
|
||||||
samesite="lax",
|
|
||||||
path="/login",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_config_page(
|
|
||||||
request: Request,
|
|
||||||
*,
|
|
||||||
settings: Settings,
|
|
||||||
auth_db_session: Session,
|
|
||||||
current_auth: AuthenticatedSession,
|
|
||||||
status_code: int,
|
|
||||||
password_change_error: str | None,
|
|
||||||
) -> HTMLResponse:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"config.html",
|
|
||||||
{
|
|
||||||
"app_name": settings.app_name,
|
|
||||||
"app_env": settings.app_env,
|
|
||||||
"current_username": current_auth.user.username,
|
|
||||||
"csrf_token": current_auth.session.csrf_token,
|
|
||||||
"force_password_change": current_auth.user.force_password_change,
|
|
||||||
"password_change_error": password_change_error,
|
|
||||||
"config_error": None,
|
|
||||||
"config_saved": False,
|
|
||||||
"config_sections": build_config_sections(auth_db_session, settings),
|
|
||||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
|
||||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
|
||||||
"ticktick_oauth_notice": None,
|
|
||||||
"ticktick_oauth_error": None,
|
|
||||||
},
|
|
||||||
status_code=status_code,
|
|
||||||
)
|
|
||||||
@@ -11,7 +11,6 @@ from app.dependencies import (
|
|||||||
get_app_settings,
|
get_app_settings,
|
||||||
get_db,
|
get_db,
|
||||||
get_homeassistant_client,
|
get_homeassistant_client,
|
||||||
get_poo_db,
|
|
||||||
get_ticktick_client,
|
get_ticktick_client,
|
||||||
)
|
)
|
||||||
from app.integrations.homeassistant import (
|
from app.integrations.homeassistant import (
|
||||||
@@ -36,7 +35,6 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
|||||||
async def publish_from_homeassistant(
|
async def publish_from_homeassistant(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
poo_db: Session = Depends(get_poo_db),
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
settings: Settings = Depends(get_app_settings),
|
||||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||||
@@ -49,7 +47,7 @@ async def publish_from_homeassistant(
|
|||||||
db,
|
db,
|
||||||
envelope,
|
envelope,
|
||||||
ticktick_client=ticktick_client,
|
ticktick_client=ticktick_client,
|
||||||
poo_session=poo_db,
|
poo_session=db,
|
||||||
settings=settings,
|
settings=settings,
|
||||||
homeassistant_client=homeassistant_client,
|
homeassistant_client=homeassistant_client,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, status
|
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
|
||||||
from app.dependencies import get_app_settings, get_auth_db, get_current_auth_session
|
|
||||||
from app.services.auth import AuthenticatedSession
|
|
||||||
from app.services.config_page import (
|
|
||||||
ConfigSaveError,
|
|
||||||
build_config_sections,
|
|
||||||
is_ticktick_oauth_ready,
|
|
||||||
save_config_updates,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
|
|
||||||
router = APIRouter(tags=["pages"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticktick_oauth_notice(status_value: str | None) -> tuple[str | None, str | None]:
|
|
||||||
if status_value == "success":
|
|
||||||
return "TickTick authorization completed successfully.", None
|
|
||||||
if status_value == "invalid-state":
|
|
||||||
return None, "TickTick authorization failed due to invalid OAuth state. Start the flow again."
|
|
||||||
if status_value == "invalid-callback":
|
|
||||||
return None, "TickTick authorization callback was missing required parameters."
|
|
||||||
if status_value == "failed":
|
|
||||||
return None, "TickTick authorization failed. Check server logs for the provider response and verify TickTick app credentials and redirect URI."
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
|
||||||
def home(
|
|
||||||
request: Request,
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
if current_auth is None:
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin", response_class=HTMLResponse)
|
|
||||||
def admin_redirect(
|
|
||||||
request: Request,
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> RedirectResponse:
|
|
||||||
if current_auth is None:
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
return RedirectResponse(url="/config", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_class=HTMLResponse)
|
|
||||||
def config_page(
|
|
||||||
request: Request,
|
|
||||||
auth_db_session: Session = Depends(get_auth_db),
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> Response:
|
|
||||||
if current_auth is None:
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
ticktick_oauth_notice, ticktick_oauth_error = _ticktick_oauth_notice(
|
|
||||||
request.query_params.get("ticktick_oauth")
|
|
||||||
)
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"app_name": settings.app_name,
|
|
||||||
"app_env": settings.app_env,
|
|
||||||
"current_username": current_auth.user.username,
|
|
||||||
"csrf_token": current_auth.session.csrf_token,
|
|
||||||
"force_password_change": current_auth.user.force_password_change,
|
|
||||||
"password_change_error": None,
|
|
||||||
"config_error": None,
|
|
||||||
"config_saved": request.query_params.get("saved") == "1",
|
|
||||||
"config_sections": build_config_sections(auth_db_session, settings),
|
|
||||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
|
||||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
|
||||||
"ticktick_oauth_notice": ticktick_oauth_notice,
|
|
||||||
"ticktick_oauth_error": ticktick_oauth_error,
|
|
||||||
}
|
|
||||||
return templates.TemplateResponse(request, "config.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_class=HTMLResponse)
|
|
||||||
async def config_submit(
|
|
||||||
request: Request,
|
|
||||||
auth_db_session: Session = Depends(get_auth_db),
|
|
||||||
settings: Settings = Depends(get_app_settings),
|
|
||||||
current_auth: AuthenticatedSession | None = Depends(get_current_auth_session),
|
|
||||||
) -> Response:
|
|
||||||
if current_auth is None:
|
|
||||||
return RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
|
|
||||||
form = await request.form()
|
|
||||||
csrf_token = form.get("csrf_token")
|
|
||||||
if csrf_token != current_auth.session.csrf_token:
|
|
||||||
logger.warning("Rejected config update due to CSRF validation failure")
|
|
||||||
context = {
|
|
||||||
"app_name": settings.app_name,
|
|
||||||
"app_env": settings.app_env,
|
|
||||||
"current_username": current_auth.user.username,
|
|
||||||
"csrf_token": current_auth.session.csrf_token,
|
|
||||||
"force_password_change": current_auth.user.force_password_change,
|
|
||||||
"password_change_error": None,
|
|
||||||
"config_error": "invalid config update request",
|
|
||||||
"config_saved": False,
|
|
||||||
"config_sections": build_config_sections(auth_db_session, settings),
|
|
||||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(settings),
|
|
||||||
"ticktick_redirect_uri": settings.ticktick_redirect_uri,
|
|
||||||
"ticktick_oauth_notice": None,
|
|
||||||
"ticktick_oauth_error": None,
|
|
||||||
}
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"config.html",
|
|
||||||
context,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
save_config_updates(auth_db_session, dict(form), settings)
|
|
||||||
except ConfigSaveError:
|
|
||||||
logger.warning("Rejected config update due to invalid submitted values")
|
|
||||||
refreshed_settings = get_settings()
|
|
||||||
context = {
|
|
||||||
"app_name": refreshed_settings.app_name,
|
|
||||||
"app_env": refreshed_settings.app_env,
|
|
||||||
"current_username": current_auth.user.username,
|
|
||||||
"csrf_token": current_auth.session.csrf_token,
|
|
||||||
"force_password_change": current_auth.user.force_password_change,
|
|
||||||
"password_change_error": None,
|
|
||||||
"config_error": "invalid config submission",
|
|
||||||
"config_saved": False,
|
|
||||||
"config_sections": build_config_sections(auth_db_session, refreshed_settings),
|
|
||||||
"ticktick_oauth_ready": is_ticktick_oauth_ready(refreshed_settings),
|
|
||||||
"ticktick_redirect_uri": refreshed_settings.ticktick_redirect_uri,
|
|
||||||
"ticktick_oauth_notice": None,
|
|
||||||
"ticktick_oauth_error": None,
|
|
||||||
}
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
request,
|
|
||||||
"config.html",
|
|
||||||
context,
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
)
|
|
||||||
|
|
||||||
return RedirectResponse(url="/config?saved=1", status_code=status.HTTP_303_SEE_OTHER)
|
|
||||||
@@ -7,7 +7,7 @@ from pydantic import ValidationError
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.dependencies import get_app_settings, get_homeassistant_client, get_poo_db
|
from app.dependencies import get_app_settings, get_homeassistant_client, get_db
|
||||||
from app.integrations.homeassistant import HomeAssistantClient
|
from app.integrations.homeassistant import HomeAssistantClient
|
||||||
from app.schemas.poo import PooRecordRequest
|
from app.schemas.poo import PooRecordRequest
|
||||||
from app.services.poo import publish_latest_poo_status, record_poo
|
from app.services.poo import publish_latest_poo_status, record_poo
|
||||||
@@ -21,7 +21,7 @@ INTERNAL_SERVER_ERROR_MESSAGE = "internal server error"
|
|||||||
@router.post("/poo/record")
|
@router.post("/poo/record")
|
||||||
async def create_poo_record(
|
async def create_poo_record(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_poo_db),
|
db: Session = Depends(get_db),
|
||||||
settings: Settings = Depends(get_app_settings),
|
settings: Settings = Depends(get_app_settings),
|
||||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -56,7 +56,7 @@ async def create_poo_record(
|
|||||||
|
|
||||||
@router.get("/poo/latest")
|
@router.get("/poo/latest")
|
||||||
def notify_latest_poo(
|
def notify_latest_poo(
|
||||||
db: Session = Depends(get_poo_db),
|
db: Session = Depends(get_db),
|
||||||
settings: Settings = Depends(get_app_settings),
|
settings: Settings = Depends(get_app_settings),
|
||||||
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
homeassistant_client: HomeAssistantClient = Depends(get_homeassistant_client),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.dependencies import (
|
from app.dependencies import (
|
||||||
get_app_settings,
|
get_app_settings,
|
||||||
get_auth_db,
|
get_db,
|
||||||
get_current_auth_session,
|
get_current_auth_session,
|
||||||
get_ticktick_client,
|
get_ticktick_client,
|
||||||
)
|
)
|
||||||
@@ -39,7 +39,7 @@ def start_ticktick_auth(
|
|||||||
@router.get("/ticktick/auth/code")
|
@router.get("/ticktick/auth/code")
|
||||||
def handle_ticktick_auth_code(
|
def handle_ticktick_auth_code(
|
||||||
request: Request,
|
request: Request,
|
||||||
auth_db_session: Session = Depends(get_auth_db),
|
auth_db_session: Session = Depends(get_db),
|
||||||
settings: Settings = Depends(get_app_settings),
|
settings: Settings = Depends(get_app_settings),
|
||||||
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
ticktick_client: TickTickClient = Depends(get_ticktick_client),
|
||||||
) -> Response:
|
) -> Response:
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
from collections.abc import Generator
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
class AuthBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _build_connect_args(database_url: str) -> dict[str, object]:
|
|
||||||
connect_args: dict[str, object] = {}
|
|
||||||
if database_url.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
return connect_args
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def _get_auth_engine(database_url: str):
|
|
||||||
return create_engine(database_url, connect_args=_build_connect_args(database_url))
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def _get_auth_session_local(database_url: str):
|
|
||||||
engine = _get_auth_engine(database_url)
|
|
||||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_engine():
|
|
||||||
settings = get_settings()
|
|
||||||
return _get_auth_engine(settings.app_database_url)
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_session_local():
|
|
||||||
settings = get_settings()
|
|
||||||
return _get_auth_session_local(settings.app_database_url)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_auth_db_caches() -> None:
|
|
||||||
_get_auth_session_local.cache_clear()
|
|
||||||
_get_auth_engine.cache_clear()
|
|
||||||
|
|
||||||
|
|
||||||
def get_auth_db_session() -> Generator[Session, None, None]:
|
|
||||||
session_local = get_auth_session_local()
|
|
||||||
session = session_local()
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
+9
-13
@@ -12,9 +12,6 @@ class Settings(BaseSettings):
|
|||||||
app_hostname: str = "localhost:8000"
|
app_hostname: str = "localhost:8000"
|
||||||
app_database_url: str = "sqlite:///./data/app.db"
|
app_database_url: str = "sqlite:///./data/app.db"
|
||||||
|
|
||||||
location_database_url: str = "sqlite:///./data/locationRecorder.db"
|
|
||||||
poo_database_url: str = "sqlite:///./data/pooRecorder.db"
|
|
||||||
|
|
||||||
ticktick_client_id: str = ""
|
ticktick_client_id: str = ""
|
||||||
ticktick_client_secret: str = ""
|
ticktick_client_secret: str = ""
|
||||||
ticktick_token: str = ""
|
ticktick_token: str = ""
|
||||||
@@ -23,6 +20,15 @@ class Settings(BaseSettings):
|
|||||||
home_assistant_auth_token: str = ""
|
home_assistant_auth_token: str = ""
|
||||||
home_assistant_timeout_seconds: float = 1.0
|
home_assistant_timeout_seconds: float = 1.0
|
||||||
home_assistant_action_task_project_id: str = ""
|
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_webhook_id: str = ""
|
||||||
poo_sensor_entity_name: str = "sensor.test_poo_status"
|
poo_sensor_entity_name: str = "sensor.test_poo_status"
|
||||||
poo_sensor_friendly_name: str = "Poo Status"
|
poo_sensor_friendly_name: str = "Poo Status"
|
||||||
@@ -68,21 +74,11 @@ class Settings(BaseSettings):
|
|||||||
raw_path = database_url[len(prefix) :]
|
raw_path = database_url[len(prefix) :]
|
||||||
return Path(raw_path)
|
return Path(raw_path)
|
||||||
|
|
||||||
@computed_field
|
|
||||||
@property
|
|
||||||
def location_sqlite_path(self) -> Path | None:
|
|
||||||
return self._sqlite_path_from_url(self.location_database_url)
|
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def app_sqlite_path(self) -> Path | None:
|
def app_sqlite_path(self) -> Path | None:
|
||||||
return self._sqlite_path_from_url(self.app_database_url)
|
return self._sqlite_path_from_url(self.app_database_url)
|
||||||
|
|
||||||
@computed_field
|
|
||||||
@property
|
|
||||||
def poo_sqlite_path(self) -> Path | None:
|
|
||||||
return self._sqlite_path_from_url(self.poo_database_url)
|
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def auth_cookie_secure(self) -> bool:
|
def auth_cookie_secure(self) -> bool:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from collections.abc import Generator
|
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 sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
@@ -10,18 +12,49 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
def _build_connect_args(database_url: str) -> dict[str, object]:
|
||||||
|
connect_args: dict[str, object] = {}
|
||||||
|
if database_url.startswith("sqlite"):
|
||||||
|
connect_args["check_same_thread"] = False
|
||||||
|
return connect_args
|
||||||
|
|
||||||
connect_args: dict[str, object] = {}
|
|
||||||
if settings.location_database_url.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
|
|
||||||
engine = create_engine(settings.location_database_url, connect_args=connect_args)
|
@lru_cache
|
||||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session)
|
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]:
|
def get_db_session() -> Generator[Session, None, None]:
|
||||||
session = SessionLocal()
|
session_local = get_session_local()
|
||||||
|
session = session_local()
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+3
-13
@@ -3,30 +3,20 @@ from collections.abc import Generator
|
|||||||
from fastapi import Depends, Request
|
from fastapi import Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth_db import get_auth_db_session
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.db import get_db_session
|
from app.db import get_db_session
|
||||||
from app.integrations.homeassistant import HomeAssistantClient
|
from app.integrations.homeassistant import HomeAssistantClient
|
||||||
from app.integrations.ticktick import TickTickClient
|
from app.integrations.ticktick import TickTickClient
|
||||||
from app.poo_db import get_poo_db_session
|
|
||||||
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
from app.services.auth import AuthenticatedSession, get_authenticated_session
|
||||||
from app.services.config_page import build_runtime_settings
|
from app.services.config_page import build_runtime_settings
|
||||||
|
|
||||||
|
|
||||||
def get_auth_db() -> Generator[Session, None, None]:
|
|
||||||
yield from get_auth_db_session()
|
|
||||||
|
|
||||||
|
|
||||||
def get_app_settings(session: Session = Depends(get_auth_db)) -> Settings:
|
|
||||||
return build_runtime_settings(session, get_settings())
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> Generator[Session, None, None]:
|
def get_db() -> Generator[Session, None, None]:
|
||||||
yield from get_db_session()
|
yield from get_db_session()
|
||||||
|
|
||||||
|
|
||||||
def get_poo_db() -> Generator[Session, None, None]:
|
def get_app_settings(session: Session = Depends(get_db)) -> Settings:
|
||||||
yield from get_poo_db_session()
|
return build_runtime_settings(session, get_settings())
|
||||||
|
|
||||||
|
|
||||||
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
def get_homeassistant_client(settings: Settings = Depends(get_app_settings)) -> HomeAssistantClient:
|
||||||
@@ -39,7 +29,7 @@ def get_ticktick_client(settings: Settings = Depends(get_app_settings)) -> TickT
|
|||||||
|
|
||||||
def get_current_auth_session(
|
def get_current_auth_session(
|
||||||
request: Request,
|
request: Request,
|
||||||
session: Session = Depends(get_auth_db),
|
session: Session = Depends(get_db),
|
||||||
settings: Settings = Depends(get_app_settings),
|
settings: Settings = Depends(get_app_settings),
|
||||||
) -> AuthenticatedSession | None:
|
) -> AuthenticatedSession | None:
|
||||||
raw_token = request.cookies.get(settings.auth_session_cookie_name)
|
raw_token = request.cookies.get(settings.auth_session_cookie_name)
|
||||||
|
|||||||
+84
-36
@@ -1,28 +1,55 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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 fastapi.staticfiles import StaticFiles
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import models # noqa: F401
|
from app import models # noqa: F401
|
||||||
from app.api.routes.auth import router as auth_router
|
from app.api.routes.api.config import router as api_config_router
|
||||||
from app.api.routes import pages, status
|
from app.api.routes.api.data import router as api_data_router
|
||||||
import app.auth_db as auth_db
|
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.homeassistant import router as homeassistant_router
|
||||||
from app.api.routes.location import router as location_router
|
from app.api.routes.location import router as location_router
|
||||||
from app.api.routes.poo import router as poo_router
|
from app.api.routes.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.api.routes.ticktick import router as ticktick_router
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.services.auth import AuthBootstrapError, initialize_auth_schema
|
from app.services.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.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
|
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
|
|
||||||
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
|
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:
|
def ensure_auth_db_ready() -> None:
|
||||||
session_local = auth_db.get_auth_session_local()
|
session_local = get_session_local()
|
||||||
session: Session = session_local()
|
session: Session = session_local()
|
||||||
try:
|
try:
|
||||||
validate_app_runtime_db(get_settings().app_database_url)
|
validate_app_runtime_db(get_settings().app_database_url)
|
||||||
@@ -37,42 +64,28 @@ def ensure_auth_db_ready() -> None:
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
def ensure_location_db_ready() -> None:
|
|
||||||
settings = get_settings()
|
|
||||||
if settings.location_sqlite_path is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
validate_location_runtime_db(settings.location_database_url)
|
|
||||||
except LocationDatabaseAdoptionError as exc:
|
|
||||||
raise RuntimeError(str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_poo_db_ready() -> None:
|
|
||||||
settings = get_settings()
|
|
||||||
if settings.poo_sqlite_path is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
validate_poo_runtime_db(settings.poo_database_url)
|
|
||||||
except PooDatabaseAdoptionError as exc:
|
|
||||||
raise RuntimeError(str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_runtime_dirs() -> None:
|
def ensure_runtime_dirs() -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
for path in (settings.app_sqlite_path, settings.location_sqlite_path, settings.poo_sqlite_path):
|
if settings.app_sqlite_path is not None:
|
||||||
if path is not None:
|
settings.app_sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
ensure_runtime_dirs()
|
ensure_runtime_dirs()
|
||||||
ensure_auth_db_ready()
|
ensure_auth_db_ready()
|
||||||
ensure_location_db_ready()
|
scheduler = BackgroundScheduler(timezone="UTC")
|
||||||
ensure_poo_db_ready()
|
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
|
yield
|
||||||
|
scheduler.shutdown(wait=False)
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -92,12 +105,47 @@ def create_app() -> FastAPI:
|
|||||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
app.include_router(status.router)
|
app.include_router(status.router)
|
||||||
app.include_router(auth_router)
|
app.include_router(api_config_router)
|
||||||
app.include_router(pages.router)
|
app.include_router(api_data_router)
|
||||||
|
app.include_router(api_session_router)
|
||||||
app.include_router(homeassistant_router)
|
app.include_router(homeassistant_router)
|
||||||
app.include_router(location_router)
|
app.include_router(location_router)
|
||||||
app.include_router(poo_router)
|
app.include_router(poo_router)
|
||||||
|
app.include_router(public_ip_router)
|
||||||
app.include_router(ticktick_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
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -3,5 +3,15 @@
|
|||||||
from app.models.auth import AuthSession, AuthUser
|
from app.models.auth import AuthSession, AuthUser
|
||||||
from app.models.config import AppConfigEntry
|
from app.models.config import AppConfigEntry
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
|
from app.models.poo import PooRecord
|
||||||
|
from app.models.public_ip import PublicIPHistory, PublicIPState
|
||||||
|
|
||||||
__all__ = ["AppConfigEntry", "AuthSession", "AuthUser", "Location"]
|
__all__ = [
|
||||||
|
"AppConfigEntry",
|
||||||
|
"AuthSession",
|
||||||
|
"AuthUser",
|
||||||
|
"Location",
|
||||||
|
"PooRecord",
|
||||||
|
"PublicIPHistory",
|
||||||
|
"PublicIPState",
|
||||||
|
]
|
||||||
|
|||||||
+3
-3
@@ -3,10 +3,10 @@ from datetime import datetime
|
|||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.auth_db import AuthBase
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
class AuthUser(AuthBase):
|
class AuthUser(Base):
|
||||||
__tablename__ = "auth_users"
|
__tablename__ = "auth_users"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
@@ -19,7 +19,7 @@ class AuthUser(AuthBase):
|
|||||||
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
|
sessions: Mapped[list["AuthSession"]] = relationship(back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
class AuthSession(AuthBase):
|
class AuthSession(Base):
|
||||||
__tablename__ = "auth_sessions"
|
__tablename__ = "auth_sessions"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
from app.db import Base
|
|
||||||
|
|
||||||
__all__ = ["Base"]
|
|
||||||
|
|
||||||
@@ -3,10 +3,10 @@ from datetime import datetime
|
|||||||
from sqlalchemy import DateTime, Integer, String
|
from sqlalchemy import DateTime, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.auth_db import AuthBase
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
class AppConfigEntry(AuthBase):
|
class AppConfigEntry(Base):
|
||||||
__tablename__ = "app_config"
|
__tablename__ = "app_config"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
from sqlalchemy import Float, String
|
from sqlalchemy import Float, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.poo_db import PooBase
|
from app.db import Base
|
||||||
|
|
||||||
|
|
||||||
class PooRecord(PooBase):
|
class PooRecord(Base):
|
||||||
__tablename__ = "poo_records"
|
__tablename__ = "poo_records"
|
||||||
|
|
||||||
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
|
timestamp: Mapped[str] = mapped_column(String, primary_key=True)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from collections.abc import Generator
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
|
||||||
|
|
||||||
from app.config import get_settings
|
|
||||||
|
|
||||||
|
|
||||||
class PooBase(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
connect_args: dict[str, object] = {}
|
|
||||||
if settings.poo_database_url.startswith("sqlite"):
|
|
||||||
connect_args["check_same_thread"] = False
|
|
||||||
|
|
||||||
poo_engine = create_engine(settings.poo_database_url, connect_args=connect_args)
|
|
||||||
PooSessionLocal = sessionmaker(bind=poo_engine, autoflush=False, autocommit=False, class_=Session)
|
|
||||||
|
|
||||||
|
|
||||||
def get_poo_db_session() -> Generator[Session, None, None]:
|
|
||||||
session = PooSessionLocal()
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
@@ -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,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
|
||||||
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth_db import reset_auth_db_caches
|
from app.db import reset_db_caches
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.models.config import AppConfigEntry
|
from app.models.config import AppConfigEntry
|
||||||
|
|
||||||
@@ -27,6 +27,15 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
|
|||||||
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
ConfigField("System", "APP_ENV", "app_env", "App Env"),
|
||||||
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
|
ConfigField("System", "APP_DEBUG", "app_debug", "App Debug"),
|
||||||
ConfigField("System", "APP_HOSTNAME", "app_hostname", "App Hostname"),
|
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(
|
ConfigField(
|
||||||
"Authentication",
|
"Authentication",
|
||||||
"AUTH_SESSION_COOKIE_NAME",
|
"AUTH_SESSION_COOKIE_NAME",
|
||||||
@@ -118,7 +127,7 @@ def sync_app_hostname_from_bootstrap(session: Session, bootstrap_settings: Setti
|
|||||||
current_values["APP_HOSTNAME"] = bootstrap_hostname
|
current_values["APP_HOSTNAME"] = bootstrap_hostname
|
||||||
_persist_config_values(session, current_values)
|
_persist_config_values(session, current_values)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
reset_auth_db_caches()
|
reset_db_caches()
|
||||||
|
|
||||||
|
|
||||||
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
|
def build_runtime_settings(session: Session, bootstrap_settings: Settings) -> Settings:
|
||||||
@@ -175,7 +184,7 @@ def save_config_updates(session: Session, form_data: dict[str, str], bootstrap_s
|
|||||||
_validate_config_values(merged_values, bootstrap_settings)
|
_validate_config_values(merged_values, bootstrap_settings)
|
||||||
_persist_config_values(session, merged_values)
|
_persist_config_values(session, merged_values)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
reset_auth_db_caches()
|
reset_db_caches()
|
||||||
|
|
||||||
|
|
||||||
def save_config_value(
|
def save_config_value(
|
||||||
@@ -190,7 +199,7 @@ def save_config_value(
|
|||||||
_validate_config_values(current_values, bootstrap_settings)
|
_validate_config_values(current_values, bootstrap_settings)
|
||||||
_persist_config_values(session, current_values)
|
_persist_config_values(session, current_values)
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
reset_auth_db_caches()
|
reset_db_caches()
|
||||||
|
|
||||||
|
|
||||||
def is_ticktick_oauth_ready(settings: Settings) -> bool:
|
def is_ticktick_oauth_ready(settings: Settings) -> bool:
|
||||||
@@ -251,8 +260,6 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
|||||||
"app_debug": settings.app_debug,
|
"app_debug": settings.app_debug,
|
||||||
"app_hostname": settings.app_hostname,
|
"app_hostname": settings.app_hostname,
|
||||||
"app_database_url": settings.app_database_url,
|
"app_database_url": settings.app_database_url,
|
||||||
"location_database_url": settings.location_database_url,
|
|
||||||
"poo_database_url": settings.poo_database_url,
|
|
||||||
"ticktick_client_id": settings.ticktick_client_id,
|
"ticktick_client_id": settings.ticktick_client_id,
|
||||||
"ticktick_client_secret": settings.ticktick_client_secret,
|
"ticktick_client_secret": settings.ticktick_client_secret,
|
||||||
"ticktick_token": settings.ticktick_token,
|
"ticktick_token": settings.ticktick_token,
|
||||||
@@ -260,6 +267,15 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
|
|||||||
"home_assistant_auth_token": settings.home_assistant_auth_token,
|
"home_assistant_auth_token": settings.home_assistant_auth_token,
|
||||||
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
|
"home_assistant_timeout_seconds": settings.home_assistant_timeout_seconds,
|
||||||
"home_assistant_action_task_project_id": settings.home_assistant_action_task_project_id,
|
"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_webhook_id": settings.poo_webhook_id,
|
||||||
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
|
"poo_sensor_entity_name": settings.poo_sensor_entity_name,
|
||||||
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
|
"poo_sensor_friendly_name": settings.poo_sensor_friendly_name,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import insert
|
from sqlalchemy import delete, insert, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
@@ -40,3 +40,58 @@ def record_location(session: Session, payload: LocationRecordRequest) -> None:
|
|||||||
)
|
)
|
||||||
session.execute(stmt)
|
session.execute(stmt)
|
||||||
session.commit()
|
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
|
||||||
|
|||||||
+48
-1
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from sqlalchemy import desc, insert, select
|
from sqlalchemy import delete, desc, insert, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
@@ -74,6 +74,53 @@ def record_poo(
|
|||||||
logger.warning("Failed to trigger poo webhook on Home Assistant: %s", 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:
|
def get_latest_poo_record(session: Session) -> LatestPooRecord | None:
|
||||||
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
stmt = select(PooRecord).order_by(desc(PooRecord.timestamp)).limit(1)
|
||||||
record = session.execute(stmt).scalar_one_or_none()
|
record = session.execute(stmt).scalar_one_or_none()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,16 +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="icon" href="data:,">
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="shell">
|
|
||||||
{% block content %}{% endblock %}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Config · {{ app_name }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<section class="panel">
|
|
||||||
<p class="eyebrow">Configuration</p>
|
|
||||||
<h1>Config</h1>
|
|
||||||
|
|
||||||
{% if force_password_change %}
|
|
||||||
<div class="alert">
|
|
||||||
首次登录后需要先修改密码。完成后再继续长期使用当前配置页面。
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if password_change_error %}
|
|
||||||
<div class="alert">{{ password_change_error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if config_error %}
|
|
||||||
<div class="alert">{{ config_error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if config_saved %}
|
|
||||||
<div class="notice">config saved to the app database. Some changes may require an app restart.</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ticktick_oauth_error %}
|
|
||||||
<div class="alert">{{ ticktick_oauth_error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ticktick_oauth_notice %}
|
|
||||||
<div class="notice">{{ ticktick_oauth_notice }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="meta single-column">
|
|
||||||
<div>
|
|
||||||
<dt>当前用户</dt>
|
|
||||||
<dd>admin</dd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="config-block">
|
|
||||||
<h2>Change Password</h2>
|
|
||||||
<form class="auth-form" method="post" action="/config/change-password">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span>Current Password</span>
|
|
||||||
<input type="password" name="current_password" autocomplete="current-password" required>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span>New Password</span>
|
|
||||||
<input type="password" name="new_password" autocomplete="new-password" required>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span>Confirm New Password</span>
|
|
||||||
<input type="password" name="confirm_password" autocomplete="new-password" required>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit">修改密码</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="config-block">
|
|
||||||
<h2>Config</h2>
|
|
||||||
<form class="config-form" method="post" action="/config">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
|
|
||||||
{% for section in config_sections %}
|
|
||||||
<fieldset class="config-section">
|
|
||||||
<legend>{{ section.name }}</legend>
|
|
||||||
{% for field in section.fields %}
|
|
||||||
<label>
|
|
||||||
<span>{{ field.label }}</span>
|
|
||||||
{% if field.secret %}
|
|
||||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="" placeholder="leave blank to keep current value">
|
|
||||||
<small>{% if field.configured %}configured{% else %}not configured{% endif %}</small>
|
|
||||||
{% else %}
|
|
||||||
<input type="{{ field.input_type }}" name="{{ field.env_name }}" value="{{ field.value }}">
|
|
||||||
{% endif %}
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if section.name == "TickTick" %}
|
|
||||||
<div class="integration-action-row">
|
|
||||||
<div>
|
|
||||||
<p class="integration-action-title">TickTick OAuth</p>
|
|
||||||
<p class="integration-action-copy">Redirect URI: {{ ticktick_redirect_uri or "configure APP_HOSTNAME to generate the callback URI" }}</p>
|
|
||||||
{% if ticktick_oauth_ready %}
|
|
||||||
<p class="integration-action-copy">Use the saved TickTick client settings to start the authorization flow.</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="integration-action-copy">Fill in App Hostname, TickTick Client ID, and TickTick Client Secret before starting OAuth.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if ticktick_oauth_ready %}
|
|
||||||
<a class="button-link" href="/ticktick/auth/start">Authorize TickTick</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="button-link disabled" aria-disabled="true">Authorize TickTick</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</fieldset>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<button type="submit">Save Config</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<form class="logout-form" method="post" action="/logout">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
<button type="submit">登出</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,36 +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>登录</dt>
|
|
||||||
<dd><a href="/login">/login</a></dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Notion</dt>
|
|
||||||
<dd>{{ notion_status }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}登录 · {{ app_name }}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<section class="panel auth-panel">
|
|
||||||
<p class="eyebrow">Authentication</p>
|
|
||||||
<h1>登录</h1>
|
|
||||||
<p class="lead">
|
|
||||||
登录成功后会进入受保护的 config 页面。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="alert">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form class="auth-form" method="post" action="/login">
|
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span>Username</span>
|
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
<span>Password</span>
|
|
||||||
<input type="password" name="password" autocomplete="current-password" required>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button type="submit">登录</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
+14
-12
@@ -8,15 +8,17 @@ alembic==1.18.4
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
annotated-types==0.7.0
|
annotated-types==0.7.0
|
||||||
# via pydantic
|
# via pydantic
|
||||||
argon2-cffi==25.1.0
|
|
||||||
# via -r requirements.in
|
|
||||||
argon2-cffi-bindings==25.1.0
|
|
||||||
# via argon2-cffi
|
|
||||||
anyio==4.13.0
|
anyio==4.13.0
|
||||||
# via
|
# via
|
||||||
# httpx
|
# httpx
|
||||||
# starlette
|
# starlette
|
||||||
# watchfiles
|
# 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
|
build==1.4.3
|
||||||
# via pip-tools
|
# via pip-tools
|
||||||
certifi==2026.2.25
|
certifi==2026.2.25
|
||||||
@@ -42,21 +44,19 @@ httpcore==1.0.9
|
|||||||
httptools==0.7.1
|
httptools==0.7.1
|
||||||
# via uvicorn
|
# via uvicorn
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
# via -r dev-requirements.in
|
# via
|
||||||
|
# -r dev-requirements.in
|
||||||
|
# -r requirements.in
|
||||||
idna==3.11
|
idna==3.11
|
||||||
# via
|
# via
|
||||||
# anyio
|
# anyio
|
||||||
# httpx
|
# httpx
|
||||||
iniconfig==2.3.0
|
iniconfig==2.3.0
|
||||||
# via pytest
|
# via pytest
|
||||||
jinja2==3.1.6
|
|
||||||
# via -r requirements.in
|
|
||||||
mako==1.3.11
|
mako==1.3.11
|
||||||
# via alembic
|
# via alembic
|
||||||
markupsafe==3.0.3
|
markupsafe==3.0.3
|
||||||
# via
|
# via mako
|
||||||
# jinja2
|
|
||||||
# mako
|
|
||||||
packaging==26.1
|
packaging==26.1
|
||||||
# via
|
# via
|
||||||
# build
|
# build
|
||||||
@@ -66,6 +66,8 @@ pip-tools==7.5.3
|
|||||||
# via -r dev-requirements.in
|
# via -r dev-requirements.in
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
# via pytest
|
# via pytest
|
||||||
|
pycparser==2.23
|
||||||
|
# via cffi
|
||||||
pydantic==2.13.2
|
pydantic==2.13.2
|
||||||
# via
|
# via
|
||||||
# fastapi
|
# fastapi
|
||||||
@@ -88,8 +90,6 @@ python-dotenv==1.2.2
|
|||||||
# uvicorn
|
# uvicorn
|
||||||
python-multipart==0.0.26
|
python-multipart==0.0.26
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
pycparser==2.23
|
|
||||||
# via cffi
|
|
||||||
pyyaml==6.0.3
|
pyyaml==6.0.3
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
@@ -112,6 +112,8 @@ typing-inspection==0.4.2
|
|||||||
# via
|
# via
|
||||||
# pydantic
|
# pydantic
|
||||||
# pydantic-settings
|
# pydantic-settings
|
||||||
|
tzlocal==5.3.1
|
||||||
|
# via apscheduler
|
||||||
uvicorn[standard]==0.44.0
|
uvicorn[standard]==0.44.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
uvloop==0.22.1
|
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"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build: .
|
|
||||||
+14
-16
@@ -1,29 +1,27 @@
|
|||||||
services:
|
services:
|
||||||
|
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:
|
app:
|
||||||
container_name: home-automation-app
|
container_name: home-automation-app
|
||||||
image: code.wanderingbadger.dev/tliu93/home-automation:latest
|
image: code.wanderingbadger.dev/tliu93/home-automation:latest
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
|
depends_on:
|
||||||
|
migration:
|
||||||
|
condition: service_completed_successfully
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8881:8000"
|
- "127.0.0.1:8881:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./.env:/app/.env:ro
|
- ./.env:/app/.env:ro
|
||||||
|
|
||||||
grafana:
|
|
||||||
image: grafana/grafana:latest
|
|
||||||
container_name: home-automation-grafana
|
|
||||||
depends_on:
|
|
||||||
- app
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "10.238.75.70:8882:3000"
|
|
||||||
environment:
|
|
||||||
GF_PLUGINS_PREINSTALL: frser-sqlite-datasource
|
|
||||||
volumes:
|
|
||||||
- ./data:/data/home-automation:ro
|
|
||||||
- homeautomation_grafana_storage:/var/lib/grafana
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
homeautomation_grafana_storage:
|
|
||||||
|
|||||||
@@ -2,8 +2,4 @@
|
|||||||
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
python scripts/app_db_adopt.py
|
exec "$@"
|
||||||
python scripts/location_db_adopt.py
|
|
||||||
python scripts/poo_db_adopt.py
|
|
||||||
|
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
|
||||||
@@ -23,44 +23,33 @@
|
|||||||
- 基础路由注册
|
- 基础路由注册
|
||||||
- `config.py`
|
- `config.py`
|
||||||
- 环境变量驱动的 settings
|
- 环境变量驱动的 settings
|
||||||
- `auth_db.py`
|
|
||||||
- app 级共享 auth 数据库
|
|
||||||
- `db.py`
|
- `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`
|
- `dependencies.py`
|
||||||
- 通用依赖注入
|
- 通用依赖注入
|
||||||
- `api/`
|
- `api/`
|
||||||
- HTTP routes
|
- HTTP routes
|
||||||
- 当前已迁入 `/login`、`/logout`、`/admin`
|
- `api/routes/api/`:JSON API(`/api/*` 前缀),供 React SPA 调用:会话/鉴权、配置读写、数据查询、记录 CRUD
|
||||||
- 当前已迁入 `POST /homeassistant/publish` 第一版入口
|
- 裸 ingestion 端点:`GET /public-ip/check`、`POST /homeassistant/publish`、`POST /poo/record`、`GET /poo/latest`、TickTick OAuth 等
|
||||||
- 当前已迁入 `POST /poo/record` 与 `GET /poo/latest`
|
|
||||||
- `models/`
|
- `models/`
|
||||||
- SQLAlchemy models
|
- SQLAlchemy models
|
||||||
- 当前 `auth`、`location` 与 `poo` 使用各自独立的数据库 base
|
- 所有模型(auth / config / public_ip / location / poo)共用同一个 `Base`,均落在单一 `app.db` 中
|
||||||
- `schemas/`
|
- `schemas/`
|
||||||
- Pydantic schemas
|
- Pydantic schemas
|
||||||
- `services/`
|
- `services/`
|
||||||
- 业务服务层
|
- 业务服务层
|
||||||
- 当前已迁入 config page 的 DB 持久化逻辑
|
- 当前已迁入 config page 的 DB 持久化逻辑
|
||||||
|
- 当前已迁入 public IPv4 检查、状态持久化与变化通知逻辑
|
||||||
|
- 当前已迁入 SMTP 发信与测试发信逻辑
|
||||||
- `integrations/`
|
- `integrations/`
|
||||||
- 外部系统适配层
|
- 外部系统适配层
|
||||||
- 当前已迁入 Home Assistant outbound adapter
|
- 当前已迁入 Home Assistant outbound adapter
|
||||||
- `templates/`
|
|
||||||
- Jinja2 模板
|
|
||||||
- `static/`
|
- `static/`
|
||||||
- 极简静态资源
|
- 极简静态资源
|
||||||
|
|
||||||
### `alembic_location/`
|
|
||||||
|
|
||||||
Location DB 的 migration 基础设施。
|
|
||||||
|
|
||||||
### `alembic_app/`
|
### `alembic_app/`
|
||||||
|
|
||||||
App DB 的 migration 基础设施。
|
App DB 的唯一 Alembic migration 链,同时管理 `location` / `poo_records` 表。M1 将三个独立 DB 合并进 `app.db` 后,`alembic_location/` 与 `alembic_poo/` 已退役,全部由此链统一管理。
|
||||||
|
|
||||||
### `alembic_poo/`
|
|
||||||
|
|
||||||
Poo DB 的 migration 基础设施。
|
|
||||||
|
|
||||||
### `tests/`
|
### `tests/`
|
||||||
|
|
||||||
@@ -70,16 +59,28 @@ pytest 测试目录。后续可以在这里自然扩展:
|
|||||||
- mock tests
|
- mock tests
|
||||||
- integration 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/`
|
### `scripts/`
|
||||||
|
|
||||||
辅助脚本目录。当前包含 OpenAPI 导出脚本。
|
辅助脚本目录。当前包含 OpenAPI 导出脚本(`export_openapi.py`)与数据层辅助脚本。
|
||||||
|
|
||||||
|
### `openapi/`
|
||||||
|
|
||||||
|
OpenAPI schema 静态产物(`openapi.json` / `openapi.yaml`),由 `python scripts/export_openapi.py` 生成,纳入版本控制。前端 codegen 以此为契约源。
|
||||||
|
|
||||||
## 当前约束
|
## 当前约束
|
||||||
|
|
||||||
- 当前只搭骨架,不迁业务逻辑
|
|
||||||
- 当前数据库继续使用 SQLite
|
- 当前数据库继续使用 SQLite
|
||||||
- 当前不引入前后端分离
|
- ~~当前不引入前后端分离~~ **已退役(M2)**:现为 React SPA + JSON `/api` 层,由 FastAPI 同源托管
|
||||||
- 当前不设计 Notion 模块
|
- 当前不设计 Notion 模块
|
||||||
|
- 当前通知能力仍保持极小范围,不引入独立通知中心或多渠道抽象
|
||||||
|
|
||||||
## 关于 Notion
|
## 关于 Notion
|
||||||
|
|
||||||
|
|||||||
@@ -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,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 日志
|
||||||
|
|
||||||
|
这样可以避免通知链路反过来影响主检查流程。
|
||||||
+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,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
/**
|
||||||
|
* RecordsMap — self-contained Leaflet map component (M2-T09).
|
||||||
|
*
|
||||||
|
* THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET.
|
||||||
|
* All data fetching and state lives outside; this component receives typed props.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
|
||||||
|
import * as L from 'leaflet'
|
||||||
|
import {
|
||||||
|
Icon,
|
||||||
|
DivIcon,
|
||||||
|
marker as leafletMarker,
|
||||||
|
heatLayer as leafletHeatLayer,
|
||||||
|
type HeatLayer,
|
||||||
|
} from 'leaflet'
|
||||||
|
|
||||||
|
// Leaflet CSS — must be imported once; this component is the single place.
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||||
|
|
||||||
|
// Side-effect imports (augment L with heatLayer and markerClusterGroup)
|
||||||
|
import 'leaflet.heat'
|
||||||
|
import 'leaflet.markercluster'
|
||||||
|
|
||||||
|
import { peakGridCount } from './mapUtils'
|
||||||
|
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
|
||||||
|
import type { LocationRecord, PooRecord } from '../records'
|
||||||
|
|
||||||
|
// Fix default Leaflet marker icon paths broken by Vite asset handling.
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png'
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
delete (Icon.Default.prototype as any)._getIconUrl
|
||||||
|
Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Props
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RecordsMapProps {
|
||||||
|
locationHeatPoints: HeatPoint[]
|
||||||
|
pooHeatPoints: HeatPoint[]
|
||||||
|
locationScatterPoints: LocationMapPoint[]
|
||||||
|
pooScatterPoints: PooMapPoint[]
|
||||||
|
|
||||||
|
showLocationHeat: boolean
|
||||||
|
showPooHeat: boolean
|
||||||
|
showScatter: boolean
|
||||||
|
|
||||||
|
onSelectLocation?: (record: LocationRecord) => void
|
||||||
|
onSelectPoo?: (record: PooRecord) => void
|
||||||
|
|
||||||
|
/** Map container height (CSS value). Default: '100%'. */
|
||||||
|
height?: string
|
||||||
|
|
||||||
|
/** Use dark base tiles to match the app's dark color scheme. */
|
||||||
|
dark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key.
|
||||||
|
const LIGHT_TILES = {
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
}
|
||||||
|
const DARK_TILES = {
|
||||||
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inner child: Heat layers (uses useMap hook — must be inside MapContainer)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface HeatLayerChildProps {
|
||||||
|
locationHeatPoints: HeatPoint[]
|
||||||
|
pooHeatPoints: HeatPoint[]
|
||||||
|
showLocationHeat: boolean
|
||||||
|
showPooHeat: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1
|
||||||
|
// at every zoom, so accumulated per-cell intensity equals the raw point count —
|
||||||
|
// which lets us normalize with a pixel-grid count below.
|
||||||
|
const LOC_HEAT = { radius: 20, blur: 15 }
|
||||||
|
const POO_HEAT = { radius: 25, blur: 18 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* leaflet.heat `max` (normalization denominator) for the CURRENT viewport:
|
||||||
|
* project the points that are visible (within the map size + a radius margin) to
|
||||||
|
* container pixels, then count the densest pixel cell using leaflet.heat's own
|
||||||
|
* grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot
|
||||||
|
* color; recomputing on every zoom/pan keeps it normalized to what's on screen.
|
||||||
|
*/
|
||||||
|
function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number {
|
||||||
|
if (points.length === 0) return 1
|
||||||
|
const cell = (radius + blur) / 2
|
||||||
|
const size = map.getSize()
|
||||||
|
const margin = radius + blur
|
||||||
|
const coords: Array<[number, number]> = []
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const p = map.latLngToContainerPoint([points[i][0], points[i][1]])
|
||||||
|
if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue
|
||||||
|
coords.push([p.x, p.y])
|
||||||
|
}
|
||||||
|
return peakGridCount(coords, cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeatLayers({
|
||||||
|
locationHeatPoints,
|
||||||
|
pooHeatPoints,
|
||||||
|
showLocationHeat,
|
||||||
|
showPooHeat,
|
||||||
|
}: HeatLayerChildProps) {
|
||||||
|
const map = useMap()
|
||||||
|
const locationLayerRef = useRef<HeatLayer | null>(null)
|
||||||
|
const pooLayerRef = useRef<HeatLayer | null>(null)
|
||||||
|
|
||||||
|
// Latest data/visibility in refs so the once-registered map move/zoom handler
|
||||||
|
// re-normalizes against the current points without re-subscribing.
|
||||||
|
const locPointsRef = useRef(locationHeatPoints)
|
||||||
|
const pooPointsRef = useRef(pooHeatPoints)
|
||||||
|
const showLocRef = useRef(showLocationHeat)
|
||||||
|
const showPooRef = useRef(showPooHeat)
|
||||||
|
useEffect(() => {
|
||||||
|
locPointsRef.current = locationHeatPoints
|
||||||
|
pooPointsRef.current = pooHeatPoints
|
||||||
|
showLocRef.current = showLocationHeat
|
||||||
|
showPooRef.current = showPooHeat
|
||||||
|
})
|
||||||
|
|
||||||
|
// Location heat layer
|
||||||
|
useEffect(() => {
|
||||||
|
if (!locationLayerRef.current) {
|
||||||
|
locationLayerRef.current = leafletHeatLayer([], {
|
||||||
|
...LOC_HEAT,
|
||||||
|
maxZoom: 0,
|
||||||
|
gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const layer = locationLayerRef.current
|
||||||
|
if (showLocationHeat) {
|
||||||
|
// Add the layer to the map BEFORE setLatLngs. A heat layer that is not on
|
||||||
|
// a map has a null `_map`, and `setLatLngs -> redraw` dereferences
|
||||||
|
// `_map._animating`, which throws and white-screens the SPA.
|
||||||
|
if (!map.hasLayer(layer)) map.addLayer(layer)
|
||||||
|
layer.setLatLngs(locationHeatPoints)
|
||||||
|
layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
|
||||||
|
} else {
|
||||||
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
||||||
|
}
|
||||||
|
}, [map, locationHeatPoints, showLocationHeat])
|
||||||
|
|
||||||
|
// Poo heat layer
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pooLayerRef.current) {
|
||||||
|
pooLayerRef.current = leafletHeatLayer([], {
|
||||||
|
...POO_HEAT,
|
||||||
|
maxZoom: 0,
|
||||||
|
// High-frequency poo spots reach red (per request); mid tones stay
|
||||||
|
// yellow/orange to distinguish from the location layer.
|
||||||
|
gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const layer = pooLayerRef.current
|
||||||
|
if (showPooHeat) {
|
||||||
|
// Add to the map before setLatLngs (see the location heat layer above).
|
||||||
|
if (!map.hasLayer(layer)) map.addLayer(layer)
|
||||||
|
layer.setLatLngs(pooHeatPoints)
|
||||||
|
layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
|
||||||
|
} else {
|
||||||
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (map.hasLayer(layer)) map.removeLayer(layer)
|
||||||
|
}
|
||||||
|
}, [map, pooHeatPoints, showPooHeat])
|
||||||
|
|
||||||
|
// Re-normalize each visible layer to the viewport peak on pan/zoom.
|
||||||
|
useEffect(() => {
|
||||||
|
const recompute = () => {
|
||||||
|
const loc = locationLayerRef.current
|
||||||
|
if (loc && showLocRef.current && map.hasLayer(loc)) {
|
||||||
|
loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) })
|
||||||
|
}
|
||||||
|
const poo = pooLayerRef.current
|
||||||
|
if (poo && showPooRef.current && map.hasLayer(poo)) {
|
||||||
|
poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.on('moveend', recompute)
|
||||||
|
map.on('zoomend', recompute)
|
||||||
|
return () => {
|
||||||
|
map.off('moveend', recompute)
|
||||||
|
map.off('zoomend', recompute)
|
||||||
|
}
|
||||||
|
}, [map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Inner child: Scatter / cluster layer
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ScatterLayerChildProps {
|
||||||
|
locationScatterPoints: LocationMapPoint[]
|
||||||
|
pooScatterPoints: PooMapPoint[]
|
||||||
|
showScatter: boolean
|
||||||
|
onSelectLocation?: (record: LocationRecord) => void
|
||||||
|
onSelectPoo?: (record: PooRecord) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationIcon = new Icon({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41],
|
||||||
|
})
|
||||||
|
|
||||||
|
const pooIcon = new DivIcon({
|
||||||
|
html: '<div style="font-size:20px;line-height:1;">💩</div>',
|
||||||
|
className: '',
|
||||||
|
iconSize: [24, 24],
|
||||||
|
iconAnchor: [12, 12],
|
||||||
|
})
|
||||||
|
|
||||||
|
export function ScatterLayer({
|
||||||
|
locationScatterPoints,
|
||||||
|
pooScatterPoints,
|
||||||
|
showScatter,
|
||||||
|
onSelectLocation,
|
||||||
|
onSelectPoo,
|
||||||
|
}: ScatterLayerChildProps) {
|
||||||
|
const map = useMap()
|
||||||
|
const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null)
|
||||||
|
|
||||||
|
const rebuild = useCallback(() => {
|
||||||
|
if (clusterGroupRef.current) {
|
||||||
|
map.removeLayer(clusterGroupRef.current)
|
||||||
|
clusterGroupRef.current = null
|
||||||
|
}
|
||||||
|
if (!showScatter) return
|
||||||
|
|
||||||
|
// markerClusterGroup is augmented onto the imported L namespace by the
|
||||||
|
// leaflet.markercluster side-effect import above. Using the imported
|
||||||
|
// namespace (not window.L) is what works in Vite ESM bundles.
|
||||||
|
const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false })
|
||||||
|
|
||||||
|
for (const pt of locationScatterPoints) {
|
||||||
|
const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon })
|
||||||
|
m.bindTooltip(`${pt.record.person}<br/>${pt.record.datetime}`, { sticky: true })
|
||||||
|
if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record))
|
||||||
|
group.addLayer(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pt of pooScatterPoints) {
|
||||||
|
const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon })
|
||||||
|
m.bindTooltip(`${pt.record.timestamp}<br/>${pt.record.status}`, { sticky: true })
|
||||||
|
if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record))
|
||||||
|
group.addLayer(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.addLayer(group)
|
||||||
|
clusterGroupRef.current = group
|
||||||
|
}, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rebuild()
|
||||||
|
return () => {
|
||||||
|
if (clusterGroupRef.current) {
|
||||||
|
map.removeLayer(clusterGroupRef.current)
|
||||||
|
clusterGroupRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [rebuild, map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Default map center: Beijing area. */
|
||||||
|
const DEFAULT_CENTER: [number, number] = [39.9, 116.4]
|
||||||
|
const DEFAULT_ZOOM = 11
|
||||||
|
|
||||||
|
export function RecordsMap({
|
||||||
|
locationHeatPoints,
|
||||||
|
pooHeatPoints,
|
||||||
|
locationScatterPoints,
|
||||||
|
pooScatterPoints,
|
||||||
|
showLocationHeat,
|
||||||
|
showPooHeat,
|
||||||
|
showScatter,
|
||||||
|
onSelectLocation,
|
||||||
|
onSelectPoo,
|
||||||
|
height = '100%',
|
||||||
|
dark = false,
|
||||||
|
}: RecordsMapProps) {
|
||||||
|
const tiles = dark ? DARK_TILES : LIGHT_TILES
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={DEFAULT_CENTER}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
|
||||||
|
data-testid="records-map"
|
||||||
|
>
|
||||||
|
{/* key forces a clean tile-layer swap when the color scheme changes */}
|
||||||
|
<TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />
|
||||||
|
|
||||||
|
<HeatLayers
|
||||||
|
locationHeatPoints={locationHeatPoints}
|
||||||
|
pooHeatPoints={pooHeatPoints}
|
||||||
|
showLocationHeat={showLocationHeat}
|
||||||
|
showPooHeat={showPooHeat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ScatterLayer
|
||||||
|
locationScatterPoints={locationScatterPoints}
|
||||||
|
pooScatterPoints={pooScatterPoints}
|
||||||
|
showScatter={showScatter}
|
||||||
|
onSelectLocation={onSelectLocation}
|
||||||
|
onSelectPoo={onSelectPoo}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Tests for peakGridCount — the pure pixel-grid peak counter used to normalize
|
||||||
|
* each heat layer to the densest cell visible in the current viewport.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { peakGridCount } from './mapUtils'
|
||||||
|
|
||||||
|
describe('peakGridCount', () => {
|
||||||
|
it('returns 1 for empty input (no divide-by-zero)', () => {
|
||||||
|
expect(peakGridCount([], 10)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts coords sharing a grid cell and returns the peak', () => {
|
||||||
|
const coords: Array<[number, number]> = [
|
||||||
|
[0, 0],
|
||||||
|
[3, 4], // same 10px cell as [0,0]
|
||||||
|
[9, 9], // same 10px cell
|
||||||
|
[100, 100], // different cell
|
||||||
|
]
|
||||||
|
expect(peakGridCount(coords, 10)).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('separates coords into different cells by cellSize', () => {
|
||||||
|
const coords: Array<[number, number]> = [
|
||||||
|
[0, 0],
|
||||||
|
[10, 0], // next cell over at cellSize 10
|
||||||
|
[20, 0], // next again
|
||||||
|
]
|
||||||
|
expect(peakGridCount(coords, 10)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a denser cluster yields a larger peak (drives per-layer normalization)', () => {
|
||||||
|
const dense: Array<[number, number]> = Array.from({ length: 12 }, () => [5, 5] as [number, number])
|
||||||
|
const sparse: Array<[number, number]> = [
|
||||||
|
[5, 5],
|
||||||
|
[5, 5],
|
||||||
|
]
|
||||||
|
expect(peakGridCount(dense, 10)).toBe(12)
|
||||||
|
expect(peakGridCount(sparse, 10)).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Public surface of the map module (M2-T09).
|
||||||
|
* Only RecordsMap.tsx imports leaflet — external code should not.
|
||||||
|
*/
|
||||||
|
export { RecordsMap } from './RecordsMap'
|
||||||
|
export type { RecordsMapProps } from './RecordsMap'
|
||||||
|
|
||||||
|
export {
|
||||||
|
locationsToHeatPoints,
|
||||||
|
pooToHeatPoints,
|
||||||
|
locationsToMapPoints,
|
||||||
|
pooToMapPoints,
|
||||||
|
filterPooByTimeWindow,
|
||||||
|
daysAgoISO,
|
||||||
|
nowISO,
|
||||||
|
computeCenter,
|
||||||
|
TIME_PRESETS,
|
||||||
|
presetRange,
|
||||||
|
shiftRange,
|
||||||
|
} from './mapUtils'
|
||||||
|
export type { HeatPoint, LocationMapPoint, PooMapPoint, TimePreset } from './mapUtils'
|
||||||
Vendored
+40
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Ambient type declarations for leaflet.heat (no @types package available).
|
||||||
|
*
|
||||||
|
* This file must be a MODULE (has a top-level export) so that `declare module 'leaflet'`
|
||||||
|
* is treated as an AUGMENTATION of the existing leaflet types, not a replacement.
|
||||||
|
* Without the export, the `declare module 'leaflet'` block would shadow all of @types/leaflet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// This empty export makes the file a module, enabling proper augmentation semantics.
|
||||||
|
export {}
|
||||||
|
|
||||||
|
// Augment the 'leaflet' module to add heatLayer and HeatLayer types.
|
||||||
|
declare module 'leaflet' {
|
||||||
|
type HeatLatLngTuple = [number, number] | [number, number, number]
|
||||||
|
|
||||||
|
interface HeatLayerOptions {
|
||||||
|
minOpacity?: number
|
||||||
|
maxZoom?: number
|
||||||
|
max?: number
|
||||||
|
radius?: number
|
||||||
|
blur?: number
|
||||||
|
gradient?: Record<number, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class HeatLayer extends Layer {
|
||||||
|
setLatLngs(latlngs: HeatLatLngTuple[]): this
|
||||||
|
addLatLng(latlng: HeatLatLngTuple): this
|
||||||
|
setOptions(options: HeatLayerOptions): this
|
||||||
|
redraw(): this
|
||||||
|
}
|
||||||
|
|
||||||
|
function heatLayer(latlngs: HeatLatLngTuple[], options?: HeatLayerOptions): HeatLayer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare leaflet.heat as a side-effect-only module.
|
||||||
|
declare module 'leaflet.heat' {
|
||||||
|
// Side-effect: augments the Leaflet global with the heatLayer plugin.
|
||||||
|
const _: undefined
|
||||||
|
export default _
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for mapUtils.ts — pure logic, no leaflet, runs in jsdom.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
locationsToHeatPoints,
|
||||||
|
pooToHeatPoints,
|
||||||
|
locationsToMapPoints,
|
||||||
|
pooToMapPoints,
|
||||||
|
filterPooByTimeWindow,
|
||||||
|
computeCenter,
|
||||||
|
daysAgoISO,
|
||||||
|
} from './mapUtils'
|
||||||
|
import type { LocationRecord, PooRecord } from '../records'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const loc1: LocationRecord = {
|
||||||
|
person: 'alice',
|
||||||
|
datetime: '2026-01-15T10:00:00Z',
|
||||||
|
latitude: 39.9,
|
||||||
|
longitude: 116.4,
|
||||||
|
altitude: 50,
|
||||||
|
}
|
||||||
|
const loc2: LocationRecord = {
|
||||||
|
person: 'alice',
|
||||||
|
datetime: '2026-01-20T12:00:00Z',
|
||||||
|
latitude: 39.95,
|
||||||
|
longitude: 116.45,
|
||||||
|
altitude: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const poo1: PooRecord = {
|
||||||
|
timestamp: '2026-01-10T08:00:00Z',
|
||||||
|
status: 'done',
|
||||||
|
latitude: 39.91,
|
||||||
|
longitude: 116.41,
|
||||||
|
}
|
||||||
|
const poo2: PooRecord = {
|
||||||
|
timestamp: '2026-01-20T09:00:00Z',
|
||||||
|
status: 'done',
|
||||||
|
latitude: 39.92,
|
||||||
|
longitude: 116.42,
|
||||||
|
}
|
||||||
|
const poo3: PooRecord = {
|
||||||
|
timestamp: '2026-02-01T09:00:00Z',
|
||||||
|
status: 'done',
|
||||||
|
latitude: 39.93,
|
||||||
|
longitude: 116.43,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// locationsToHeatPoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('locationsToHeatPoints', () => {
|
||||||
|
it('converts records to [lat, lng, 1] tuples', () => {
|
||||||
|
const pts = locationsToHeatPoints([loc1, loc2])
|
||||||
|
expect(pts).toHaveLength(2)
|
||||||
|
expect(pts[0]).toEqual([39.9, 116.4, 1])
|
||||||
|
expect(pts[1]).toEqual([39.95, 116.45, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array for empty input', () => {
|
||||||
|
expect(locationsToHeatPoints([])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pooToHeatPoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('pooToHeatPoints', () => {
|
||||||
|
it('converts poo records to heat points', () => {
|
||||||
|
const pts = pooToHeatPoints([poo1])
|
||||||
|
expect(pts).toHaveLength(1)
|
||||||
|
expect(pts[0]).toEqual([39.91, 116.41, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// locationsToMapPoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('locationsToMapPoints', () => {
|
||||||
|
it('attaches original record to each point', () => {
|
||||||
|
const pts = locationsToMapPoints([loc1])
|
||||||
|
expect(pts).toHaveLength(1)
|
||||||
|
expect(pts[0].lat).toBe(39.9)
|
||||||
|
expect(pts[0].lng).toBe(116.4)
|
||||||
|
expect(pts[0].record).toBe(loc1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// pooToMapPoints
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('pooToMapPoints', () => {
|
||||||
|
it('attaches original poo record to each point', () => {
|
||||||
|
const pts = pooToMapPoints([poo1])
|
||||||
|
expect(pts[0].record).toBe(poo1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// filterPooByTimeWindow — client-side time filter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('filterPooByTimeWindow', () => {
|
||||||
|
const records = [poo1, poo2, poo3]
|
||||||
|
// timestamps: 2026-01-10, 2026-01-20, 2026-02-01
|
||||||
|
|
||||||
|
it('returns all records when start and end are both null', () => {
|
||||||
|
expect(filterPooByTimeWindow(records, null, null)).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by start (inclusive)', () => {
|
||||||
|
const result = filterPooByTimeWindow(records, '2026-01-15T00:00:00Z', null)
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
|
||||||
|
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by end (inclusive)', () => {
|
||||||
|
const result = filterPooByTimeWindow(records, null, '2026-01-20T09:00:00Z')
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
|
||||||
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-20T09:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by both start and end', () => {
|
||||||
|
const result = filterPooByTimeWindow(
|
||||||
|
records,
|
||||||
|
'2026-01-15T00:00:00Z',
|
||||||
|
'2026-01-25T00:00:00Z',
|
||||||
|
)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].timestamp).toBe('2026-01-20T09:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty when no records match', () => {
|
||||||
|
const result = filterPooByTimeWindow(records, '2027-01-01T00:00:00Z', null)
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes records exactly at start boundary', () => {
|
||||||
|
const result = filterPooByTimeWindow(records, '2026-01-10T08:00:00Z', null)
|
||||||
|
expect(result.map((r) => r.timestamp)).toContain('2026-01-10T08:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes records exactly at end boundary', () => {
|
||||||
|
const result = filterPooByTimeWindow(records, null, '2026-02-01T09:00:00Z')
|
||||||
|
expect(result.map((r) => r.timestamp)).toContain('2026-02-01T09:00:00Z')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// computeCenter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('computeCenter', () => {
|
||||||
|
it('returns null for empty array', () => {
|
||||||
|
expect(computeCenter([])).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the point for a single-element array', () => {
|
||||||
|
const result = computeCenter([{ lat: 10, lng: 20 }])
|
||||||
|
expect(result).toEqual([10, 20])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the average of multiple points', () => {
|
||||||
|
const result = computeCenter([
|
||||||
|
{ lat: 0, lng: 0 },
|
||||||
|
{ lat: 4, lng: 6 },
|
||||||
|
])
|
||||||
|
expect(result).toEqual([2, 3])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// daysAgoISO
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('daysAgoISO', () => {
|
||||||
|
it('returns a valid ISO string in the past', () => {
|
||||||
|
const result = daysAgoISO(7)
|
||||||
|
expect(typeof result).toBe('string')
|
||||||
|
const d = new Date(result)
|
||||||
|
expect(isNaN(d.getTime())).toBe(false)
|
||||||
|
expect(d.getTime()).toBeLessThan(Date.now())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Pure data-transform utilities for the map view (M2-T09).
|
||||||
|
* No leaflet imports — these functions are unit-testable in jsdom.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LocationRecord, PooRecord } from '../records'
|
||||||
|
|
||||||
|
/** A heat point for L.heatLayer: [lat, lng, intensity]. */
|
||||||
|
export type HeatPoint = [number, number, number]
|
||||||
|
|
||||||
|
/** Map point with attached source record for click-to-edit. */
|
||||||
|
export interface LocationMapPoint {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
record: LocationRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PooMapPoint {
|
||||||
|
lat: number
|
||||||
|
lng: number
|
||||||
|
record: PooRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Transforms
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert location records to heat points.
|
||||||
|
* All points get intensity=1; callers can adjust if needed.
|
||||||
|
*/
|
||||||
|
export function locationsToHeatPoints(records: LocationRecord[]): HeatPoint[] {
|
||||||
|
return records.map((r) => [r.latitude, r.longitude, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert poo records to heat points.
|
||||||
|
*/
|
||||||
|
export function pooToHeatPoints(records: PooRecord[]): HeatPoint[] {
|
||||||
|
return records.map((r) => [r.latitude, r.longitude, 1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Peak number of 2D coordinates that fall into the same `cellSize`-sized grid
|
||||||
|
* cell. Pure + leaflet-free so it is unit-testable.
|
||||||
|
*
|
||||||
|
* Used by the map heat normalization: project the VISIBLE points to screen
|
||||||
|
* pixels (in the map component), then this returns the densest pixel cell's
|
||||||
|
* count, which becomes leaflet.heat's `max`. With maxZoom:0 (intensity factor
|
||||||
|
* f=1) the accumulated per-cell value equals this count, so the densest visible
|
||||||
|
* cluster maps to the hot color — recomputed on every zoom/pan so it always
|
||||||
|
* normalizes within the current viewport. Returns at least 1.
|
||||||
|
*/
|
||||||
|
export function peakGridCount(coords: Array<[number, number]>, cellSize: number): number {
|
||||||
|
if (coords.length === 0) return 1
|
||||||
|
const g = Math.max(1, cellSize)
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
let peak = 1
|
||||||
|
for (const [x, y] of coords) {
|
||||||
|
const key = `${Math.floor(x / g)}:${Math.floor(y / g)}`
|
||||||
|
const next = (counts.get(key) ?? 0) + 1
|
||||||
|
counts.set(key, next)
|
||||||
|
if (next > peak) peak = next
|
||||||
|
}
|
||||||
|
return peak
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert location records to map points (for scatter layer).
|
||||||
|
*/
|
||||||
|
export function locationsToMapPoints(records: LocationRecord[]): LocationMapPoint[] {
|
||||||
|
return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert poo records to map points (for scatter layer).
|
||||||
|
*/
|
||||||
|
export function pooToMapPoints(records: PooRecord[]): PooMapPoint[] {
|
||||||
|
return records.map((r) => ({ lat: r.latitude, lng: r.longitude, record: r }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Client-side time-window filter (for poo records — the endpoint has no server filter)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter poo records to those whose timestamp falls within [start, end] (inclusive).
|
||||||
|
* start and end are ISO8601 strings (e.g. "2026-01-01T00:00:00Z").
|
||||||
|
* If start or end is null, that bound is open (no filtering on that side).
|
||||||
|
*/
|
||||||
|
export function filterPooByTimeWindow(
|
||||||
|
records: PooRecord[],
|
||||||
|
start: string | null,
|
||||||
|
end: string | null,
|
||||||
|
): PooRecord[] {
|
||||||
|
if (!start && !end) return records
|
||||||
|
return records.filter((r) => {
|
||||||
|
const ts = r.timestamp
|
||||||
|
if (start && ts < start) return false
|
||||||
|
if (end && ts > end) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default time window helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Returns ISO8601 string for N days ago from now (UTC). */
|
||||||
|
export function daysAgoISO(days: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCDate(d.getUTCDate() - days)
|
||||||
|
return d.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns ISO8601 string for now (UTC). */
|
||||||
|
export function nowISO(): string {
|
||||||
|
return new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compute a bounding center from an array of lat/lng points. Returns null if empty. */
|
||||||
|
export function computeCenter(
|
||||||
|
points: Array<{ lat: number; lng: number }>,
|
||||||
|
): [number, number] | null {
|
||||||
|
if (points.length === 0) return null
|
||||||
|
const sumLat = points.reduce((s, p) => s + p.lat, 0)
|
||||||
|
const sumLng = points.reduce((s, p) => s + p.lng, 0)
|
||||||
|
return [sumLat / points.length, sumLng / points.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Quick time-range presets + window shifting (Grafana-style)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const HOUR_MS = 3_600_000
|
||||||
|
const DAY_MS = 24 * HOUR_MS
|
||||||
|
|
||||||
|
/** A quick-range preset: a label + a span in milliseconds (month/year approximated). */
|
||||||
|
export interface TimePreset {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
spanMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIME_PRESETS: TimePreset[] = [
|
||||||
|
{ value: '24h', label: 'Past 24 hours', spanMs: 24 * HOUR_MS },
|
||||||
|
{ value: '1w', label: 'Past 1 week', spanMs: 7 * DAY_MS },
|
||||||
|
{ value: '2w', label: 'Past 2 weeks', spanMs: 14 * DAY_MS },
|
||||||
|
{ value: '1mo', label: 'Past 1 month', spanMs: 30 * DAY_MS },
|
||||||
|
{ value: '6mo', label: 'Past 6 months', spanMs: 182 * DAY_MS },
|
||||||
|
{ value: '1y', label: 'Past 1 year', spanMs: 365 * DAY_MS },
|
||||||
|
{ value: '5y', label: 'Past 5 years', spanMs: 5 * 365 * DAY_MS },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** ISO8601 with second precision, no milliseconds: "YYYY-MM-DDTHH:MM:SSZ". */
|
||||||
|
function isoSeconds(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 19) + 'Z'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute a [start, end] window of width `spanMs` ending at `now`.
|
||||||
|
* Used when the user picks a quick-range preset.
|
||||||
|
*/
|
||||||
|
export function presetRange(spanMs: number, now: Date = new Date()): { start: string; end: string } {
|
||||||
|
return { start: isoSeconds(new Date(now.getTime() - spanMs)), end: isoSeconds(now) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift a [start, end] window by its OWN span. direction = -1 moves earlier
|
||||||
|
* (back in time), +1 moves later. The window width is preserved.
|
||||||
|
*/
|
||||||
|
export function shiftRange(
|
||||||
|
startISO: string,
|
||||||
|
endISO: string,
|
||||||
|
direction: -1 | 1,
|
||||||
|
): { start: string; end: string } {
|
||||||
|
const startMs = Date.parse(startISO)
|
||||||
|
const endMs = Date.parse(endISO)
|
||||||
|
const span = endMs - startMs
|
||||||
|
return {
|
||||||
|
start: isoSeconds(new Date(startMs + direction * span)),
|
||||||
|
end: isoSeconds(new Date(endMs + direction * span)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the quick-range preset + window-shift helpers (Grafana-style).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { TIME_PRESETS, presetRange, shiftRange } from './mapUtils'
|
||||||
|
|
||||||
|
describe('TIME_PRESETS', () => {
|
||||||
|
it('exposes the 7 expected quick ranges in order', () => {
|
||||||
|
expect(TIME_PRESETS.map((p) => p.value)).toEqual([
|
||||||
|
'24h',
|
||||||
|
'1w',
|
||||||
|
'2w',
|
||||||
|
'1mo',
|
||||||
|
'6mo',
|
||||||
|
'1y',
|
||||||
|
'5y',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('presetRange', () => {
|
||||||
|
const now = new Date('2026-06-13T12:00:00Z')
|
||||||
|
|
||||||
|
it('ends at now and spans the given duration (24h)', () => {
|
||||||
|
const { start, end } = presetRange(24 * 3_600_000, now)
|
||||||
|
expect(end).toBe('2026-06-13T12:00:00Z')
|
||||||
|
expect(start).toBe('2026-06-12T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('spans a week', () => {
|
||||||
|
const { start, end } = presetRange(7 * 24 * 3_600_000, now)
|
||||||
|
expect(end).toBe('2026-06-13T12:00:00Z')
|
||||||
|
expect(start).toBe('2026-06-06T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits second-precision ISO with no milliseconds', () => {
|
||||||
|
const { start, end } = presetRange(3_600_000, now)
|
||||||
|
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||||
|
expect(end).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('shiftRange', () => {
|
||||||
|
it('moves a 24h window back by 24h when direction = -1', () => {
|
||||||
|
const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', -1)
|
||||||
|
expect(start).toBe('2026-06-11T12:00:00Z')
|
||||||
|
expect(end).toBe('2026-06-12T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves a 24h window forward by 24h when direction = +1', () => {
|
||||||
|
const { start, end } = shiftRange('2026-06-12T12:00:00Z', '2026-06-13T12:00:00Z', 1)
|
||||||
|
expect(start).toBe('2026-06-13T12:00:00Z')
|
||||||
|
expect(end).toBe('2026-06-14T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shifts by the window OWN span (a 1-week window moves a week)', () => {
|
||||||
|
const { start, end } = shiftRange('2026-06-06T12:00:00Z', '2026-06-13T12:00:00Z', -1)
|
||||||
|
expect(start).toBe('2026-05-30T12:00:00Z')
|
||||||
|
expect(end).toBe('2026-06-06T12:00:00Z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is reversible: shift back then forward returns to the original window', () => {
|
||||||
|
const orig = { start: '2026-06-06T12:00:00Z', end: '2026-06-13T12:00:00Z' }
|
||||||
|
const back = shiftRange(orig.start, orig.end, -1)
|
||||||
|
const fwd = shiftRange(back.start, back.end, 1)
|
||||||
|
expect(fwd).toEqual(orig)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ChangePasswordPage (M2-T07 rework-1).
|
||||||
|
*
|
||||||
|
* Strategy: vi.mock the apiClient and useSession modules so we can control
|
||||||
|
* POST /api/auth/password responses and session state without a real server.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders the change-password form when user has force_password_change=true.
|
||||||
|
* 2. Successful password change → navigates to '/' (proceeds into the app).
|
||||||
|
* 3. Client-side mismatch → shows error, does NOT call the API.
|
||||||
|
* 4. API 400 error → shows generic error, stays on form.
|
||||||
|
* 5. Guard: non-forced user visiting /change-password → redirected to '/'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { ChangePasswordPage } from './ChangePasswordPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
GET: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock useSession — default: forced-change user
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockUseSession = vi.fn(() => ({
|
||||||
|
status: 'authenticated' as 'loading' | 'authenticated' | 'unauthenticated',
|
||||||
|
user: { username: 'admin', force_password_change: true } as
|
||||||
|
| null
|
||||||
|
| { username: string; force_password_change: boolean },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../auth/SessionProvider', () => ({
|
||||||
|
useSession: () => mockUseSession(),
|
||||||
|
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderChangePw(initialPath = '/change-password') {
|
||||||
|
return renderWithProviders(<ChangePasswordPage />, {
|
||||||
|
initialPath,
|
||||||
|
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAndSubmit(currentPw: string, newPw: string, confirmPw: string) {
|
||||||
|
fireEvent.change(screen.getByTestId('current-password-input'), {
|
||||||
|
target: { value: currentPw },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('new-password-input'), {
|
||||||
|
target: { value: newPw },
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByTestId('confirm-password-input'), {
|
||||||
|
target: { value: confirmPw },
|
||||||
|
})
|
||||||
|
fireEvent.submit(screen.getByTestId('change-password-form'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ChangePasswordPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Default: authenticated user with force_password_change=true
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: { username: 'admin', force_password_change: true },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the change-password form for a forced-change user', () => {
|
||||||
|
renderChangePw()
|
||||||
|
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('current-password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('new-password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('confirm-password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('change-password-submit')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to "/" after a successful password change', async () => {
|
||||||
|
// Simulate successful POST /api/auth/password
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('old-password', 'new-password', 'new-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/auth/password with the correct body', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('current123', 'newpass456', 'newpass456')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/auth/password', {
|
||||||
|
body: {
|
||||||
|
current_password: 'current123',
|
||||||
|
new_password: 'newpass456',
|
||||||
|
confirm_password: 'newpass456',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error and does NOT call the API when new passwords do not match', async () => {
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('current-pw', 'new-pw-1', 'new-pw-2')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
|
||||||
|
/do not match/i,
|
||||||
|
)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
// Should remain on the form
|
||||||
|
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows generic error on API 400 and stays on form', async () => {
|
||||||
|
// Simulate 400 via ApiError throw (as the client middleware does)
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPost.mockRejectedValueOnce(new ApiError(400, { detail: 'wrong password' }))
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
fillAndSubmit('wrong-current', 'newpass', 'newpass')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('change-password-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('change-password-error')).toHaveTextContent(
|
||||||
|
/password change failed/i,
|
||||||
|
)
|
||||||
|
// Should NOT have navigated away
|
||||||
|
expect(screen.getByTestId('change-password-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects a non-forced user away from /change-password to "/"', async () => {
|
||||||
|
// A user who has already changed their password
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: { username: 'admin', force_password_change: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderChangePw()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// The change-password form must NOT be shown
|
||||||
|
expect(screen.queryByTestId('change-password-form')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* ChangePasswordPage — forced password change gate (M2-T07).
|
||||||
|
*
|
||||||
|
* Shown when the authenticated user has force_password_change === true.
|
||||||
|
* Blocks access to all other pages until the password is changed.
|
||||||
|
*
|
||||||
|
* Behaviours:
|
||||||
|
* - If the current user does NOT have force_password_change, redirect to '/'
|
||||||
|
* (mirrors LoginPage's already-authenticated guard).
|
||||||
|
* - POST /api/auth/password with { current_password, new_password, confirm_password }.
|
||||||
|
* - On ApiError 400 → show a generic failure message (do not leak details).
|
||||||
|
* - On success → invalidate ['session'] so SessionProvider re-fetches with
|
||||||
|
* force_password_change=false, then navigate to '/' to enter the app.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
PasswordInput,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Center,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useSession } from '../auth/SessionProvider'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { ApiError } from '../api/client'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ChangePasswordPage() {
|
||||||
|
const { user } = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Guard: if the user is authenticated but NOT in forced-change state, redirect
|
||||||
|
// to the app. This prevents a non-forced user from sitting on /change-password.
|
||||||
|
// (Mirrors LoginPage's already-authenticated redirect.)
|
||||||
|
if (user && !user.force_password_change) {
|
||||||
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||||
|
return <Navigate to={from} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Client-side validation: confirm passwords match before hitting the server.
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
setError('New passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.POST('/api/auth/password', {
|
||||||
|
body: {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
confirm_password: confirmPassword,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Success: refresh session so force_password_change becomes false,
|
||||||
|
// then navigate into the app — the guard above (and ProtectedRoute) will
|
||||||
|
// no longer block access once the session is updated.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
navigate('/', { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 400) {
|
||||||
|
// Generic failure message — do not leak backend detail.
|
||||||
|
setError('Password change failed. Please check your current password and try again.')
|
||||||
|
} else {
|
||||||
|
setError('An unexpected error occurred. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center mih="100vh">
|
||||||
|
<Container size="xs" w="100%">
|
||||||
|
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||||
|
<Title order={2} mb="xs" ta="center">
|
||||||
|
Change Password
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="sm" mb="lg" ta="center">
|
||||||
|
You must change your password before continuing.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" mb="md" role="alert" data-testid="change-password-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} data-testid="change-password-form">
|
||||||
|
<Stack gap="md">
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
placeholder="Enter your current password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
data-testid="current-password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="New Password"
|
||||||
|
placeholder="Enter your new password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-testid="new-password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm New Password"
|
||||||
|
placeholder="Confirm your new password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
data-testid="confirm-password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
mt="sm"
|
||||||
|
data-testid="change-password-submit"
|
||||||
|
>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ConfigPage (M2-T08).
|
||||||
|
*
|
||||||
|
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
|
||||||
|
* without a real server.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders config sections from a mocked GET /api/config response.
|
||||||
|
* 2. Secret fields start as empty (never display masked value).
|
||||||
|
* 3. Non-secret fields show their loaded values.
|
||||||
|
* 4. Save: updates map includes all non-secret fields and excludes untouched secrets.
|
||||||
|
* 5. Save: updates map includes a secret only when the user typed a new value.
|
||||||
|
* 6. Save success → shows success notice.
|
||||||
|
* 7. Save error → shows error notice.
|
||||||
|
* 8. SMTP test button: success state (200 result=success).
|
||||||
|
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
|
||||||
|
* 10. SMTP test button: failed state (502/ApiError result=failed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { ConfigPage } from './ConfigPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixture: config sections
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MOCK_CONFIG = {
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
name: 'General',
|
||||||
|
fields: [
|
||||||
|
{ env_name: 'APP_NAME', label: 'App Name', value: 'My Home', secret: false, input_type: 'text', configured: true },
|
||||||
|
{ env_name: 'APP_PORT', label: 'Port', value: '8000', secret: false, input_type: 'number', configured: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'SMTP',
|
||||||
|
fields: [
|
||||||
|
{ env_name: 'SMTP_HOST', label: 'SMTP Host', value: 'smtp.example.com', secret: false, input_type: 'text', configured: true },
|
||||||
|
{ env_name: 'SMTP_PASSWORD', label: 'SMTP Password', value: '', secret: true, input_type: 'password', configured: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockGet = vi.fn()
|
||||||
|
const mockPut = vi.fn()
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: (...args: unknown[]) => mockGet(...args),
|
||||||
|
PUT: (...args: unknown[]) => mockPut(...args),
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderConfig() {
|
||||||
|
return renderWithProviders(<ConfigPage />, { initialPath: '/config' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('ConfigPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Default: GET /api/config returns the fixture
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 1. Renders sections
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders section names and field labels', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('General')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('SMTP')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('App Name')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SMTP Host')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 2. Secret fields start empty
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders secret fields with empty value (never displays masked value)', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mantine puts data-testid on the <input> element itself
|
||||||
|
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
|
||||||
|
expect(secretInput.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 3. Non-secret fields show their loaded values
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('renders non-secret fields with their loaded values', async () => {
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('field-APP_NAME')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mantine puts data-testid on the <input> element itself for TextInput
|
||||||
|
const appNameInput = screen.getByTestId('field-APP_NAME') as HTMLInputElement
|
||||||
|
expect(appNameInput.value).toBe('My Home')
|
||||||
|
|
||||||
|
const smtpHostInput = screen.getByTestId('field-SMTP_HOST') as HTMLInputElement
|
||||||
|
expect(smtpHostInput.value).toBe('smtp.example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 4. Save: updates includes all non-secrets, excludes untouched secrets
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('save sends all non-secret fields and excludes untouched (blank) secrets', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
||||||
|
// After save, refetch
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submit without touching any field
|
||||||
|
fireEvent.submit(screen.getByTestId('config-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const putCall = mockPut.mock.calls[0]
|
||||||
|
const body = putCall[1].body as { updates: Record<string, string> }
|
||||||
|
const updates = body.updates
|
||||||
|
|
||||||
|
// Non-secret fields MUST be present
|
||||||
|
expect(updates).toHaveProperty('APP_NAME', 'My Home')
|
||||||
|
expect(updates).toHaveProperty('APP_PORT', '8000')
|
||||||
|
expect(updates).toHaveProperty('SMTP_HOST', 'smtp.example.com')
|
||||||
|
|
||||||
|
// Untouched secret field MUST NOT be present
|
||||||
|
expect(updates).not.toHaveProperty('SMTP_PASSWORD')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 5. Save: updates includes secret when user typed a new value
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('save includes a secret field when the user typed a new value', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
||||||
|
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('field-secret-SMTP_PASSWORD')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mantine puts data-testid on the <input> element itself
|
||||||
|
const secretInput = screen.getByTestId('field-secret-SMTP_PASSWORD') as HTMLInputElement
|
||||||
|
fireEvent.change(secretInput, { target: { value: 'new-secret-value' } })
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('config-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPut).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
const putCall = mockPut.mock.calls[0]
|
||||||
|
const body = putCall[1].body as { updates: Record<string, string> }
|
||||||
|
const updates = body.updates
|
||||||
|
|
||||||
|
// Secret MUST be included because the user typed a value
|
||||||
|
expect(updates).toHaveProperty('SMTP_PASSWORD', 'new-secret-value')
|
||||||
|
// Non-secrets still present
|
||||||
|
expect(updates).toHaveProperty('APP_NAME', 'My Home')
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 6. Save success → shows success notice
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows success alert after a successful save', async () => {
|
||||||
|
mockPut.mockResolvedValueOnce({ data: {}, response: { status: 200, ok: true } })
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('config-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('save-success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 7. Save error → shows error notice
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows error alert when save fails', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPut.mockRejectedValueOnce(new ApiError(422, { detail: 'invalid value' }))
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('config-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByTestId('config-form'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('save-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('save-success')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 8. SMTP test button: success state
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows success alert after SMTP test succeeds', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: { result: 'success', message: 'Email delivered.' },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('smtp-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('smtp-result-success')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 9. SMTP test button: config-error state (400)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows config-error alert when SMTP test returns config-error', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPost.mockRejectedValueOnce(
|
||||||
|
new ApiError(400, { result: 'config-error', message: 'SMTP host not configured.' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('smtp-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('smtp-result-config-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('smtp-result-failed')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 10. SMTP test button: failed state (502)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
it('shows failed alert when SMTP test returns failed', async () => {
|
||||||
|
const { ApiError } = await import('../api/client')
|
||||||
|
mockPost.mockRejectedValueOnce(
|
||||||
|
new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderConfig()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('smtp-test-button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('smtp-test-button'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('smtp-result-failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
/**
|
||||||
|
* ConfigPage — config editor (M2-T08).
|
||||||
|
*
|
||||||
|
* Behaviours:
|
||||||
|
* 1. Load config: GET /api/config → render sections (grouped) with Mantine inputs.
|
||||||
|
* - Non-secret fields show their value.
|
||||||
|
* - Secret fields render as empty PasswordInput (never show a masked value).
|
||||||
|
* 2. Save config: PUT /api/config with full-field submission semantics.
|
||||||
|
* - All non-secret fields are ALWAYS included (to avoid backend zeroing absent fields).
|
||||||
|
* - Secret fields are included ONLY when the user typed a new (non-empty) value.
|
||||||
|
* - On success: show a success notice and refetch config.
|
||||||
|
* - On ApiError 422: show an error notice, nothing was written.
|
||||||
|
* 3. SMTP test button: POST /api/config/smtp/test.
|
||||||
|
* - Tri-state: success / config-error / failed.
|
||||||
|
* - Errors read `err.body.result` from ApiError.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Divider,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Paper,
|
||||||
|
Badge,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import apiClient, { ApiError } from '../api/client'
|
||||||
|
import type { components } from '../api/schema.d.ts'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ConfigField = components['schemas']['ConfigField']
|
||||||
|
type ConfigSection = components['schemas']['ConfigSection']
|
||||||
|
|
||||||
|
/** SMTP test result tri-state. */
|
||||||
|
type SmtpResult =
|
||||||
|
| { kind: 'success'; message: string }
|
||||||
|
| { kind: 'config-error'; message: string }
|
||||||
|
| { kind: 'failed'; message: string }
|
||||||
|
| null
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook: load config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useConfig() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['config'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/config')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: build updates map for PUT /api/config
|
||||||
|
//
|
||||||
|
// Full-field submission semantics (§6):
|
||||||
|
// - Non-secret fields: ALWAYS include current value (even if unchanged) so
|
||||||
|
// the backend does not zero out absent fields.
|
||||||
|
// - Secret fields: include ONLY when the user typed a non-empty value.
|
||||||
|
// Blank secret = keep old value; sending blank would also keep it per
|
||||||
|
// backend semantics, but we omit it to be explicit and avoid confusion.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function buildUpdates(
|
||||||
|
sections: ConfigSection[],
|
||||||
|
localValues: Record<string, string>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const updates: Record<string, string> = {}
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
for (const field of section.fields) {
|
||||||
|
const localVal = localValues[field.env_name] ?? ''
|
||||||
|
if (field.secret) {
|
||||||
|
// Only include secret if the user typed something (non-empty).
|
||||||
|
if (localVal !== '') {
|
||||||
|
updates[field.env_name] = localVal
|
||||||
|
}
|
||||||
|
// blank secret → omit → backend keeps the existing stored value
|
||||||
|
} else {
|
||||||
|
// Non-secret: always include current local value.
|
||||||
|
updates[field.env_name] = localVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigFieldInput — renders a single config field
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ConfigFieldInputProps {
|
||||||
|
field: ConfigField
|
||||||
|
value: string
|
||||||
|
onChange: (envName: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onChange(field.env_name, e.currentTarget.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.secret) {
|
||||||
|
return (
|
||||||
|
<PasswordInput
|
||||||
|
label={field.label}
|
||||||
|
placeholder={field.configured ? '(configured — leave blank to keep)' : 'Enter value'}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid={`field-secret-${field.env_name}`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.input_type === 'number') {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label={field.label}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid={`field-${field.env_name}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
label={field.label}
|
||||||
|
type={field.input_type === 'email' ? 'email' : 'text'}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
data-testid={`field-${field.env_name}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigSectionPanel — one section
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ConfigSectionPanelProps {
|
||||||
|
section: ConfigSection
|
||||||
|
localValues: Record<string, string>
|
||||||
|
onChange: (envName: string, value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
|
||||||
|
return (
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
{section.name}
|
||||||
|
</Title>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{section.fields.map((field) => (
|
||||||
|
<ConfigFieldInput
|
||||||
|
key={field.env_name}
|
||||||
|
field={field}
|
||||||
|
value={localValues[field.env_name] ?? ''}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SmtpTestButton — sends POST /api/config/smtp/test and displays tri-state result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SmtpTestButtonProps {
|
||||||
|
smtpResult: SmtpResult
|
||||||
|
setSmtpResult: (r: SmtpResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) {
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
async function handleTest() {
|
||||||
|
setSmtpResult(null)
|
||||||
|
setTesting(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.POST('/api/config/smtp/test')
|
||||||
|
if (res.data) {
|
||||||
|
setSmtpResult({ kind: 'success', message: res.data.message })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const body = err.body as { result?: string; message?: string } | null
|
||||||
|
const result = body?.result
|
||||||
|
const message = body?.message ?? 'Unknown error'
|
||||||
|
if (result === 'config-error') {
|
||||||
|
setSmtpResult({ kind: 'config-error', message })
|
||||||
|
} else {
|
||||||
|
// result === 'failed' or any other error
|
||||||
|
setSmtpResult({ kind: 'failed', message })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSmtpResult({ kind: 'failed', message: 'Unexpected error sending test email.' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
loading={testing}
|
||||||
|
data-testid="smtp-test-button"
|
||||||
|
>
|
||||||
|
Send Test Email
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{smtpResult?.kind === 'success' && (
|
||||||
|
<Alert color="green" data-testid="smtp-result-success">
|
||||||
|
Test email sent successfully. {smtpResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{smtpResult?.kind === 'config-error' && (
|
||||||
|
<Alert color="orange" data-testid="smtp-result-config-error">
|
||||||
|
SMTP configuration error — check your SMTP settings. {smtpResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{smtpResult?.kind === 'failed' && (
|
||||||
|
<Alert color="red" data-testid="smtp-result-failed">
|
||||||
|
Test email send failed. {smtpResult.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ConfigPage — main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function ConfigPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading, isError } = useConfig()
|
||||||
|
|
||||||
|
// Local field values — mirrors the loaded config but allows user edits.
|
||||||
|
// Secret fields always start as empty string (never display masked values).
|
||||||
|
const [localValues, setLocalValues] = useState<Record<string, string>>({})
|
||||||
|
const [valuesInitialized, setValuesInitialized] = useState(false)
|
||||||
|
|
||||||
|
// Initialise local state once when data arrives (or re-arrives after refetch).
|
||||||
|
if (data && !valuesInitialized) {
|
||||||
|
const initial: Record<string, string> = {}
|
||||||
|
for (const section of data.sections) {
|
||||||
|
for (const field of section.fields) {
|
||||||
|
// Secret fields start empty (never display the masked/empty backend value).
|
||||||
|
initial[field.env_name] = field.secret ? '' : (field.value ?? '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLocalValues(initial)
|
||||||
|
setValuesInitialized(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save notice state
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null)
|
||||||
|
|
||||||
|
// SMTP test tri-state
|
||||||
|
const [smtpResult, setSmtpResult] = useState<SmtpResult>(null)
|
||||||
|
|
||||||
|
function handleChange(envName: string, value: string) {
|
||||||
|
setLocalValues((prev) => ({ ...prev, [envName]: value }))
|
||||||
|
setSaveStatus(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!data) return
|
||||||
|
const updates = buildUpdates(data.sections, localValues)
|
||||||
|
await apiClient.PUT('/api/config', { body: { updates } })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaveStatus(null)
|
||||||
|
try {
|
||||||
|
await saveMutation.mutateAsync()
|
||||||
|
setSaveStatus('success')
|
||||||
|
// Refetch config so the page reflects the saved state.
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['config'] })
|
||||||
|
// After refetch, reset initialised flag so local state rebuilds from fresh data.
|
||||||
|
setValuesInitialized(false)
|
||||||
|
} catch {
|
||||||
|
setSaveStatus('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render states
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center pt="xl">
|
||||||
|
<Loader data-testid="config-loading" />
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<Container pt="xl">
|
||||||
|
<Alert color="red" data-testid="config-load-error">
|
||||||
|
Failed to load configuration. Please refresh the page.
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if there is an SMTP section (to show the test button).
|
||||||
|
const hasSmtpSection = data.sections.some((s) =>
|
||||||
|
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
|
||||||
|
<Group justify="space-between" mb="lg" wrap="nowrap">
|
||||||
|
<Title order={2}>Configuration</Title>
|
||||||
|
<Badge variant="outline" color="gray" size="sm">
|
||||||
|
{data.sections.length} section{data.sections.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} data-testid="config-form">
|
||||||
|
<Stack gap="lg">
|
||||||
|
{data.sections.map((section) => (
|
||||||
|
<ConfigSectionPanel
|
||||||
|
key={section.name}
|
||||||
|
section={section}
|
||||||
|
localValues={localValues}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{saveStatus === 'success' && (
|
||||||
|
<Alert color="green" data-testid="save-success">
|
||||||
|
Configuration saved successfully.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<Alert color="red" data-testid="save-error">
|
||||||
|
Failed to save configuration. Please check the values and try again.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
data-testid="config-save-button"
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{hasSmtpSection && (
|
||||||
|
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{!hasSmtpSection && (
|
||||||
|
<Stack mt="md">
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Configure SMTP settings to enable email notifications.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* HomePage tests — M2-T09.
|
||||||
|
*
|
||||||
|
* Leaflet is mocked so jsdom doesn't choke on DOM APIs it doesn't support.
|
||||||
|
* We verify:
|
||||||
|
* 1. Controls render (time range inputs, layer toggles, apply button).
|
||||||
|
* 2. Point-select: when onSelectLocation is called, EditLocationModal opens.
|
||||||
|
* 3. Point-select: when onSelectPoo is called, EditPooModal opens.
|
||||||
|
* 4. The map component is rendered (mocked).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { MantineProvider } from '@mantine/core'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock leaflet / react-leaflet before any component imports them.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('leaflet', () => ({
|
||||||
|
default: {},
|
||||||
|
Icon: { Default: { prototype: {}, mergeOptions: vi.fn() } },
|
||||||
|
DivIcon: vi.fn(() => ({})),
|
||||||
|
heatLayer: vi.fn(() => ({ setLatLngs: vi.fn(), addTo: vi.fn() })),
|
||||||
|
markerClusterGroup: vi.fn(() => ({ addLayer: vi.fn(), clearLayers: vi.fn() })),
|
||||||
|
marker: vi.fn(() => ({
|
||||||
|
bindTooltip: vi.fn().mockReturnThis(),
|
||||||
|
on: vi.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
tileLayer: vi.fn(),
|
||||||
|
map: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('leaflet.heat', () => ({}))
|
||||||
|
vi.mock('leaflet.markercluster', () => ({}))
|
||||||
|
|
||||||
|
vi.mock('react-leaflet', () => ({
|
||||||
|
MapContainer: ({ children }: { children: ReactNode }) => (
|
||||||
|
<div data-testid="records-map">{children}</div>
|
||||||
|
),
|
||||||
|
TileLayer: () => null,
|
||||||
|
useMap: () => ({
|
||||||
|
addLayer: vi.fn(),
|
||||||
|
removeLayer: vi.fn(),
|
||||||
|
hasLayer: vi.fn(() => false),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock leaflet 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 leaflet CSS
|
||||||
|
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||||
|
vi.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({}))
|
||||||
|
vi.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock RecordsMap to capture onSelectLocation / onSelectPoo callbacks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import type { RecordsMapProps } from '../map/RecordsMap'
|
||||||
|
|
||||||
|
let capturedOnSelectLocation: RecordsMapProps['onSelectLocation'] | undefined
|
||||||
|
let capturedOnSelectPoo: RecordsMapProps['onSelectPoo'] | undefined
|
||||||
|
|
||||||
|
vi.mock('../map/RecordsMap', () => ({
|
||||||
|
RecordsMap: (props: RecordsMapProps) => {
|
||||||
|
capturedOnSelectLocation = props.onSelectLocation
|
||||||
|
capturedOnSelectPoo = props.onSelectPoo
|
||||||
|
return <div data-testid="records-map-mock" />
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient — return minimal data so queries resolve
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
GET: vi.fn(async (path: string) => {
|
||||||
|
if (path === '/api/locations') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
person: 'alice',
|
||||||
|
datetime: '2026-01-15T10:00:00Z',
|
||||||
|
latitude: 39.9,
|
||||||
|
longitude: 116.4,
|
||||||
|
altitude: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limit: 5000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (path === '/api/poo') {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
timestamp: '2026-01-20T09:00:00Z',
|
||||||
|
status: 'done',
|
||||||
|
latitude: 39.91,
|
||||||
|
longitude: 116.41,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
limit: 1000,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { data: null }
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Now import components under test (after mocks are registered)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import { HomePage } from './HomePage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test wrapper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeQC() {
|
||||||
|
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wrapper({ qc, children }: { qc: QueryClient; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<MantineProvider>
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>{children}</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: render HomePage and wait for queries to resolve
|
||||||
|
async function renderHomePage() {
|
||||||
|
const qc = makeQC()
|
||||||
|
const utils = render(
|
||||||
|
<Wrapper qc={qc}>
|
||||||
|
<HomePage />
|
||||||
|
</Wrapper>,
|
||||||
|
)
|
||||||
|
// Wait for the map mock to appear (data loaded)
|
||||||
|
await waitFor(() => screen.getByTestId('records-map-mock'))
|
||||||
|
return utils
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('HomePage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedOnSelectLocation = undefined
|
||||||
|
capturedOnSelectPoo = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders time-range controls', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
expect(screen.getByTestId('time-start-input')).toBeTruthy()
|
||||||
|
expect(screen.getByTestId('time-end-input')).toBeTruthy()
|
||||||
|
expect(screen.getByTestId('apply-window-button')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders layer toggle switches', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
expect(screen.getByTestId('toggle-location-heat')).toBeTruthy()
|
||||||
|
expect(screen.getByTestId('toggle-poo-heat')).toBeTruthy()
|
||||||
|
expect(screen.getByTestId('toggle-scatter')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the RecordsMap component', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
expect(screen.getByTestId('records-map-mock')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens EditLocationModal when onSelectLocation is called with a location record', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
|
||||||
|
// Simulate clicking a location scatter point
|
||||||
|
const record = {
|
||||||
|
person: 'alice',
|
||||||
|
datetime: '2026-01-15T10:00:00Z',
|
||||||
|
latitude: 39.9,
|
||||||
|
longitude: 116.4,
|
||||||
|
altitude: null,
|
||||||
|
}
|
||||||
|
expect(capturedOnSelectLocation).toBeDefined()
|
||||||
|
capturedOnSelectLocation!(record)
|
||||||
|
|
||||||
|
// EditLocationModal should appear
|
||||||
|
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
||||||
|
expect(screen.getByTestId('edit-location-modal')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens EditPooModal when onSelectPoo is called with a poo record', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
timestamp: '2026-01-20T09:00:00Z',
|
||||||
|
status: 'done',
|
||||||
|
latitude: 39.91,
|
||||||
|
longitude: 116.41,
|
||||||
|
}
|
||||||
|
expect(capturedOnSelectPoo).toBeDefined()
|
||||||
|
capturedOnSelectPoo!(record)
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
||||||
|
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes EditLocationModal when Cancel is clicked', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
person: 'alice',
|
||||||
|
datetime: '2026-01-15T10:00:00Z',
|
||||||
|
latitude: 39.9,
|
||||||
|
longitude: 116.4,
|
||||||
|
altitude: null,
|
||||||
|
}
|
||||||
|
capturedOnSelectLocation!(record)
|
||||||
|
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('edit-location-cancel'))
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('edit-location-modal')).toBeNull())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes EditPooModal when Cancel is clicked', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
timestamp: '2026-01-20T09:00:00Z',
|
||||||
|
status: 'done',
|
||||||
|
latitude: 39.91,
|
||||||
|
longitude: 116.41,
|
||||||
|
}
|
||||||
|
capturedOnSelectPoo!(record)
|
||||||
|
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('edit-poo-modal')).toBeNull())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('time-range inputs have default values', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
const startInput = screen.getByTestId('time-start-input') as HTMLInputElement
|
||||||
|
const endInput = screen.getByTestId('time-end-input') as HTMLInputElement
|
||||||
|
expect(startInput.value).toBeTruthy()
|
||||||
|
expect(endInput.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply button re-triggers data fetch with new window', async () => {
|
||||||
|
await renderHomePage()
|
||||||
|
const startInput = screen.getByTestId('time-start-input') as HTMLInputElement
|
||||||
|
fireEvent.change(startInput, { target: { value: '2026-01-01T00:00' } })
|
||||||
|
fireEvent.click(screen.getByTestId('apply-window-button'))
|
||||||
|
// Just verify no crash; data refresh happens async via React Query.
|
||||||
|
await waitFor(() => screen.getByTestId('records-map-mock'))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* HomePage — data-visualization map view (M2-T09).
|
||||||
|
*
|
||||||
|
* Renders a heat map of location records (where you've been) and poo records
|
||||||
|
* (where the dog poops), plus a toggleable scatter layer for point-select
|
||||||
|
* edit/delete (reusing T10's modals + hooks).
|
||||||
|
*
|
||||||
|
* Data fetching and all state live here; the map itself is fully isolated in
|
||||||
|
* src/map/RecordsMap.tsx (the ONLY place that imports leaflet).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Stack,
|
||||||
|
Group,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Loader,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
useComputedColorScheme,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'react-feather'
|
||||||
|
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import {
|
||||||
|
locationsToHeatPoints,
|
||||||
|
pooToHeatPoints,
|
||||||
|
locationsToMapPoints,
|
||||||
|
pooToMapPoints,
|
||||||
|
filterPooByTimeWindow,
|
||||||
|
daysAgoISO,
|
||||||
|
nowISO,
|
||||||
|
TIME_PRESETS,
|
||||||
|
presetRange,
|
||||||
|
shiftRange,
|
||||||
|
} from '../map'
|
||||||
|
import { RecordsMap } from '../map'
|
||||||
|
import {
|
||||||
|
EditLocationModal,
|
||||||
|
EditPooModal,
|
||||||
|
ConfirmDeleteModal,
|
||||||
|
useDeleteLocation,
|
||||||
|
useDeletePoo,
|
||||||
|
} from '../records'
|
||||||
|
import type { LocationRecord, PooRecord } from '../records'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data hooks (query-key prefix: ['locations', ...] / ['poo', ...])
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useLocations(start: string | null, end: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['locations', { start, end, limit: 5000 }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/locations', {
|
||||||
|
params: {
|
||||||
|
query: {
|
||||||
|
limit: 5000,
|
||||||
|
offset: 0,
|
||||||
|
...(start ? { start } : {}),
|
||||||
|
...(end ? { end } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return res.data?.items ?? []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poo endpoint has no server-side time filter — fetch a large page (max 1000)
|
||||||
|
* and client-filter by timestamp below.
|
||||||
|
*/
|
||||||
|
function usePoo() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['poo', { limit: 1000 }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiClient.GET('/api/poo', {
|
||||||
|
params: { query: { limit: 1000, offset: 0 } },
|
||||||
|
})
|
||||||
|
return res.data?.items ?? []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Point-select state (which record is selected + which modal to show)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type SelectionState =
|
||||||
|
| { kind: 'none' }
|
||||||
|
| { kind: 'editLocation'; record: LocationRecord }
|
||||||
|
| { kind: 'deleteLocation'; record: LocationRecord }
|
||||||
|
| { kind: 'editPoo'; record: PooRecord }
|
||||||
|
| { kind: 'deletePoo'; record: PooRecord }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HomePage
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function HomePage() {
|
||||||
|
// ------ Time-window state -----------------------------------------------
|
||||||
|
// Default: last 30 days → now
|
||||||
|
const [startInput, setStartInput] = useState(() => {
|
||||||
|
const d = new Date()
|
||||||
|
d.setUTCDate(d.getUTCDate() - 30)
|
||||||
|
return d.toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM"
|
||||||
|
})
|
||||||
|
const [endInput, setEndInput] = useState(() => nowISO().slice(0, 16))
|
||||||
|
// Applied (committed) window — updated on Apply / preset / shift
|
||||||
|
const [appliedStart, setAppliedStart] = useState<string | null>(() => daysAgoISO(30))
|
||||||
|
const [appliedEnd, setAppliedEnd] = useState<string | null>(() => nowISO())
|
||||||
|
// Which quick-range preset is currently active (null = custom / shifted range)
|
||||||
|
const [activePreset, setActivePreset] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Set both the committed window and the editable inputs from an ISO [start, end].
|
||||||
|
function setWindow(startISO: string, endISO: string) {
|
||||||
|
setAppliedStart(startISO)
|
||||||
|
setAppliedEnd(endISO)
|
||||||
|
setStartInput(startISO.slice(0, 16))
|
||||||
|
setEndInput(endISO.slice(0, 16))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a quick range: fill from-to ending at now, apply immediately (Grafana-style).
|
||||||
|
function applyPreset(value: string | null) {
|
||||||
|
const preset = TIME_PRESETS.find((p) => p.value === value)
|
||||||
|
if (!preset) return
|
||||||
|
const { start, end } = presetRange(preset.spanMs)
|
||||||
|
setWindow(start, end)
|
||||||
|
setActivePreset(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift the committed window by its own span. -1 = earlier, +1 = later.
|
||||||
|
function shiftWindow(direction: -1 | 1) {
|
||||||
|
if (!appliedStart || !appliedEnd) return
|
||||||
|
const { start, end } = shiftRange(appliedStart, appliedEnd, direction)
|
||||||
|
setWindow(start, end)
|
||||||
|
// A shifted window is an absolute range, no longer "now - X".
|
||||||
|
setActivePreset(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------ Layer toggle state -----------------------------------------------
|
||||||
|
const [showLocationHeat, setShowLocationHeat] = useState(true)
|
||||||
|
const [showPooHeat, setShowPooHeat] = useState(true)
|
||||||
|
const [showScatter, setShowScatter] = useState(false)
|
||||||
|
|
||||||
|
// ------ Data fetching ----------------------------------------------------
|
||||||
|
const locationsQuery = useLocations(appliedStart, appliedEnd)
|
||||||
|
const pooQuery = usePoo()
|
||||||
|
|
||||||
|
// Client-side time-filter for poo (server has no filter)
|
||||||
|
const filteredPoo = useMemo(
|
||||||
|
() => filterPooByTimeWindow(pooQuery.data ?? [], appliedStart, appliedEnd),
|
||||||
|
[pooQuery.data, appliedStart, appliedEnd],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Derived map data
|
||||||
|
const locationHeatPoints = useMemo(
|
||||||
|
() => locationsToHeatPoints(locationsQuery.data ?? []),
|
||||||
|
[locationsQuery.data],
|
||||||
|
)
|
||||||
|
const pooHeatPoints = useMemo(
|
||||||
|
() => pooToHeatPoints(filteredPoo),
|
||||||
|
[filteredPoo],
|
||||||
|
)
|
||||||
|
const locationScatterPoints = useMemo(
|
||||||
|
() => locationsToMapPoints(locationsQuery.data ?? []),
|
||||||
|
[locationsQuery.data],
|
||||||
|
)
|
||||||
|
const pooScatterPoints = useMemo(
|
||||||
|
() => pooToMapPoints(filteredPoo),
|
||||||
|
[filteredPoo],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ------ Point-select state -----------------------------------------------
|
||||||
|
const [selection, setSelection] = useState<SelectionState>({ kind: 'none' })
|
||||||
|
|
||||||
|
const deleteLocationMut = useDeleteLocation()
|
||||||
|
const deletePooMut = useDeletePoo()
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
function handleSelectLocation(record: LocationRecord) {
|
||||||
|
setSelection({ kind: 'editLocation', record })
|
||||||
|
}
|
||||||
|
function handleSelectPoo(record: PooRecord) {
|
||||||
|
setSelection({ kind: 'editPoo', record })
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWindow() {
|
||||||
|
// Convert local datetime-local inputs (which have no TZ) to ISO8601
|
||||||
|
// by appending :00Z if needed. Input is "YYYY-MM-DDTHH:MM".
|
||||||
|
const toISO = (s: string) => (s ? s + ':00Z' : null)
|
||||||
|
setAppliedStart(toISO(startInput))
|
||||||
|
setAppliedEnd(toISO(endInput))
|
||||||
|
// Manually-applied range is custom, not a preset.
|
||||||
|
setActivePreset(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------ Render -----------------------------------------------------------
|
||||||
|
const isLoading = locationsQuery.isLoading || pooQuery.isLoading
|
||||||
|
const isError = locationsQuery.isError || pooQuery.isError
|
||||||
|
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ height: 'calc(100vh - 52px)', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* Controls bar */}
|
||||||
|
<Paper
|
||||||
|
shadow="xs"
|
||||||
|
p="xs"
|
||||||
|
style={{ zIndex: 1000, flexShrink: 0 }}
|
||||||
|
data-testid="map-controls"
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
{/* Time-range row */}
|
||||||
|
<Group gap="xs" align="flex-end" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
label="From"
|
||||||
|
type="datetime-local"
|
||||||
|
value={startInput}
|
||||||
|
onChange={(e) => setStartInput(e.currentTarget.value)}
|
||||||
|
size="xs"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
data-testid="time-start-input"
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="To"
|
||||||
|
type="datetime-local"
|
||||||
|
value={endInput}
|
||||||
|
onChange={(e) => setEndInput(e.currentTarget.value)}
|
||||||
|
size="xs"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
data-testid="time-end-input"
|
||||||
|
/>
|
||||||
|
{/* Quick range + shift buttons (Grafana-style) — between To and Apply.
|
||||||
|
zIndex raised above Leaflet (~1000) so the dropdown/tooltips are
|
||||||
|
not painted over by the map below. */}
|
||||||
|
<Group gap={4} align="flex-end">
|
||||||
|
<Select
|
||||||
|
label="Quick range"
|
||||||
|
placeholder="Pick a range"
|
||||||
|
data={TIME_PRESETS.map((p) => ({ value: p.value, label: p.label }))}
|
||||||
|
value={activePreset}
|
||||||
|
onChange={applyPreset}
|
||||||
|
size="xs"
|
||||||
|
allowDeselect={false}
|
||||||
|
style={{ width: 150 }}
|
||||||
|
comboboxProps={{ zIndex: 3000 }}
|
||||||
|
data-testid="quick-range-select"
|
||||||
|
/>
|
||||||
|
<Tooltip label="Shift earlier (one window back)" zIndex={3000}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="input-xs"
|
||||||
|
aria-label="Shift earlier"
|
||||||
|
onClick={() => shiftWindow(-1)}
|
||||||
|
data-testid="shift-earlier"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Shift later (one window forward)" zIndex={3000}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="input-xs"
|
||||||
|
aria-label="Shift later"
|
||||||
|
onClick={() => shiftWindow(1)}
|
||||||
|
data-testid="shift-later"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button size="xs" onClick={applyWindow} data-testid="apply-window-button">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
{isLoading && <Loader size="xs" />}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Layer toggles row */}
|
||||||
|
<Group gap="md" wrap="wrap">
|
||||||
|
<Switch
|
||||||
|
label={
|
||||||
|
<Group gap={4}>
|
||||||
|
<Text size="xs">Location heat</Text>
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
{locationsQuery.data?.length ?? 0}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
checked={showLocationHeat}
|
||||||
|
onChange={(e) => setShowLocationHeat(e.currentTarget.checked)}
|
||||||
|
size="xs"
|
||||||
|
data-testid="toggle-location-heat"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={
|
||||||
|
<Group gap={4}>
|
||||||
|
<Text size="xs">Poo heat</Text>
|
||||||
|
<Badge size="xs" color="orange" variant="light">
|
||||||
|
{filteredPoo.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
checked={showPooHeat}
|
||||||
|
onChange={(e) => setShowPooHeat(e.currentTarget.checked)}
|
||||||
|
size="xs"
|
||||||
|
data-testid="toggle-poo-heat"
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
label={<Text size="xs">Scatter (click to edit)</Text>}
|
||||||
|
checked={showScatter}
|
||||||
|
onChange={(e) => setShowScatter(e.currentTarget.checked)}
|
||||||
|
size="xs"
|
||||||
|
data-testid="toggle-scatter"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{isError && (
|
||||||
|
<Alert color="red" data-testid="map-error-alert">
|
||||||
|
Failed to load data. Check connection and refresh.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Map fills remaining height. `isolation: isolate` traps Leaflet's internal
|
||||||
|
z-indexes (panes/controls up to ~1000) in their own stacking context so
|
||||||
|
they can't paint over portaled popups (Quick-range dropdown, tooltips,
|
||||||
|
and the point-select edit/delete modals). */}
|
||||||
|
<Box style={{ flex: 1, minHeight: 0, isolation: 'isolate' }}>
|
||||||
|
<RecordsMap
|
||||||
|
locationHeatPoints={locationHeatPoints}
|
||||||
|
pooHeatPoints={pooHeatPoints}
|
||||||
|
locationScatterPoints={locationScatterPoints}
|
||||||
|
pooScatterPoints={pooScatterPoints}
|
||||||
|
showLocationHeat={showLocationHeat}
|
||||||
|
showPooHeat={showPooHeat}
|
||||||
|
showScatter={showScatter}
|
||||||
|
onSelectLocation={handleSelectLocation}
|
||||||
|
onSelectPoo={handleSelectPoo}
|
||||||
|
height="100%"
|
||||||
|
dark={colorScheme === 'dark'}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ---------- Point-select modals ---------- */}
|
||||||
|
|
||||||
|
{selection.kind === 'editLocation' && (
|
||||||
|
<EditLocationModal
|
||||||
|
record={selection.record}
|
||||||
|
onClose={() => setSelection({ kind: 'none' })}
|
||||||
|
onSaved={() => setSelection({ kind: 'none' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection.kind === 'deleteLocation' && (
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
message={`Delete location record for ${selection.record.person} at ${selection.record.datetime}?`}
|
||||||
|
loading={deleteLocationMut.isPending}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteLocationMut.mutateAsync({
|
||||||
|
person: selection.record.person,
|
||||||
|
datetime: selection.record.datetime,
|
||||||
|
})
|
||||||
|
setSelection({ kind: 'none' })
|
||||||
|
}}
|
||||||
|
onCancel={() => setSelection({ kind: 'none' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection.kind === 'editPoo' && (
|
||||||
|
<EditPooModal
|
||||||
|
record={selection.record}
|
||||||
|
onClose={() => setSelection({ kind: 'none' })}
|
||||||
|
onSaved={() => {
|
||||||
|
// After saving, optionally switch to delete prompt or just close.
|
||||||
|
setSelection({ kind: 'none' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection.kind === 'deletePoo' && (
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
message={`Delete poo record at ${selection.record.timestamp}?`}
|
||||||
|
loading={deletePooMut.isPending}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deletePooMut.mutateAsync(selection.record.timestamp)
|
||||||
|
setSelection({ kind: 'none' })
|
||||||
|
}}
|
||||||
|
onCancel={() => setSelection({ kind: 'none' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Tests for LoginPage (M2-T07).
|
||||||
|
*
|
||||||
|
* Strategy: vi.mock the apiClient module so we can control POST /api/auth/login
|
||||||
|
* responses without a real server. We also mock useSession so tests can control
|
||||||
|
* the authentication state.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* 1. Renders the login form.
|
||||||
|
* 2. Successful login → invalidates session query + navigates.
|
||||||
|
* 3. 401 bad credentials → shows inline error, does not navigate.
|
||||||
|
* 4. Already-authenticated users visiting /login → redirected to '/'.
|
||||||
|
* 5. Unexpected error → shows generic error message.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { renderWithProviders } from '../test-utils'
|
||||||
|
import { LoginPage } from './LoginPage'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock apiClient
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// We mock the entire api/client module. Each test can override POST as needed.
|
||||||
|
const mockPost = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../api/client', () => ({
|
||||||
|
default: {
|
||||||
|
POST: (...args: unknown[]) => mockPost(...args),
|
||||||
|
GET: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError: class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
body: unknown
|
||||||
|
constructor(status: number, body: unknown) {
|
||||||
|
super(`API error ${status}`)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registerLoginRedirect: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mock useSession — default: unauthenticated
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Typed as returning the wider union so mockReturnValue accepts all status variants.
|
||||||
|
const mockUseSession = vi.fn(() => ({
|
||||||
|
status: 'unauthenticated' as 'loading' | 'authenticated' | 'unauthenticated',
|
||||||
|
user: null as null | { username: string; force_password_change: boolean },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../auth/SessionProvider', () => ({
|
||||||
|
useSession: () => mockUseSession(),
|
||||||
|
SessionProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderLogin(initialPath = '/login') {
|
||||||
|
return renderWithProviders(<LoginPage />, {
|
||||||
|
initialPath,
|
||||||
|
routes: [{ path: '/', element: <div data-testid="home-page">Home</div> }],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAndSubmit(username: string, password: string) {
|
||||||
|
fireEvent.change(screen.getByTestId('username-input'), { target: { value: username } })
|
||||||
|
fireEvent.change(screen.getByTestId('password-input'), { target: { value: password } })
|
||||||
|
fireEvent.submit(screen.getByTestId('login-form'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('LoginPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset to unauthenticated by default
|
||||||
|
mockUseSession.mockReturnValue({ status: 'unauthenticated', user: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the login form with username and password fields', () => {
|
||||||
|
renderLogin()
|
||||||
|
expect(screen.getByTestId('login-form')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('username-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('password-input')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('login-submit')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Sign In heading', () => {
|
||||||
|
renderLogin()
|
||||||
|
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates to "/" on successful login', async () => {
|
||||||
|
// Simulate a successful POST /api/auth/login response
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'correct-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls POST /api/auth/login with the correct body', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: { user: { username: 'admin', force_password_change: false }, csrf_token: 'tok123' },
|
||||||
|
response: { status: 200, ok: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('myuser', 'mypassword')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPost).toHaveBeenCalledWith('/api/auth/login', {
|
||||||
|
body: { username: 'myuser', password: 'mypassword' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows inline error on 401 and does NOT navigate', async () => {
|
||||||
|
// Simulate 401: openapi-fetch returns { data: undefined, response: { status: 401 } }
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
response: { status: 401, ok: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'wrong-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('login-error')).toHaveTextContent(
|
||||||
|
/incorrect username or password/i,
|
||||||
|
)
|
||||||
|
// Should still be on the login form, not navigated away
|
||||||
|
expect(screen.getByTestId('login-form')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not include the password in the error message', async () => {
|
||||||
|
mockPost.mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
response: { status: 401, ok: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'super-secret-password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('login-error')).not.toHaveTextContent('super-secret-password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows generic error on unexpected network failure', async () => {
|
||||||
|
mockPost.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
fillAndSubmit('admin', 'password')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('login-error')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('login-error')).toHaveTextContent(/login failed/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects already-authenticated users to "/"', async () => {
|
||||||
|
mockUseSession.mockReturnValue({
|
||||||
|
status: 'authenticated',
|
||||||
|
user: { username: 'admin', force_password_change: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
renderLogin()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('home-page')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* LoginPage — real login form (M2-T07).
|
||||||
|
*
|
||||||
|
* Behaviours:
|
||||||
|
* - Renders a Mantine form with username + password fields.
|
||||||
|
* - On submit → POST /api/auth/login via apiClient (no CSRF needed; unauthenticated endpoint).
|
||||||
|
* - On success → invalidate ['session'] so SessionProvider re-fetches, then navigate to the
|
||||||
|
* originally-requested route (from location.state.from) or fall back to '/'.
|
||||||
|
* - On 401 (bad credentials) → show an inline error without leaking the password.
|
||||||
|
* - Already-authenticated users visiting /login → redirect to '/'.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Title,
|
||||||
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
Stack,
|
||||||
|
Center,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import { useSession } from '../auth/SessionProvider'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import { setCsrfToken } from '../api/csrf'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface LocationState {
|
||||||
|
from?: { pathname: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function LoginPage() {
|
||||||
|
const { status } = useSession()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// Already authenticated → redirect to intended destination or home.
|
||||||
|
if (status === 'authenticated') {
|
||||||
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||||
|
return <Navigate to={from} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await apiClient.POST('/api/auth/login', {
|
||||||
|
body: { username, password },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.response.status === 401 || !res.data) {
|
||||||
|
// Bad credentials — do not leak the password in the message.
|
||||||
|
setError('Incorrect username or password.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success: store the CSRF token returned by login (same shape as session response).
|
||||||
|
if (res.data.csrf_token) {
|
||||||
|
setCsrfToken(res.data.csrf_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh session state: invalidate the ['session'] query so SessionProvider
|
||||||
|
// picks up the new authenticated state (which may include force_password_change).
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['session'] })
|
||||||
|
|
||||||
|
// Navigate to the originally-requested route or home.
|
||||||
|
const from = (location.state as LocationState)?.from?.pathname ?? '/'
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} catch {
|
||||||
|
// Any unexpected error (network, 5xx, etc.)
|
||||||
|
setError('Login failed. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Center mih="100vh">
|
||||||
|
<Container size="xs" w="100%">
|
||||||
|
<Paper shadow="sm" p="xl" radius="md" withBorder>
|
||||||
|
<Title order={2} mb="lg" ta="center">
|
||||||
|
Sign In
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert color="red" mb="md" role="alert" data-testid="login-error">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} data-testid="login-form">
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
data-testid="username-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
data-testid="password-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
loading={loading}
|
||||||
|
mt="sm"
|
||||||
|
data-testid="login-submit"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Center>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user