fix: computed fields missing in anniversary endpoints + missing account_id validation in installment update
This commit is contained in:
43
AGENTS.md
43
AGENTS.md
@@ -3,7 +3,7 @@
|
||||
## Project overview
|
||||
- Full-stack todo app: **Vue 3 + Element Plus** (WebUI/) + **FastAPI + SQLAlchemy** (api/)
|
||||
- **Python 3.10+, Node 18+**; SQLite database
|
||||
- Single `master` branch, no CI/CD
|
||||
- Single `master` branch (4 commits total), no CI/CD
|
||||
|
||||
## Quick commands
|
||||
|
||||
@@ -39,24 +39,37 @@ python tests/test_accounts.py
|
||||
- **Config is hardcoded** in `api/app/config.py` — no `.env`, no `os.getenv()`. Port `23994`, CORS origins `["http://localhost:5173", "http://localhost:23994"]`. Note: `pydantic-settings` is in `requirements.txt` but unused.
|
||||
- **SQLite path** is computed relative to `api/` via `__file__` — safe regardless of cwd. `connect_args={"check_same_thread": False}` for FastAPI async compatibility.
|
||||
- **`database.py:init_db()`** auto-creates tables on startup (`create_all`) and auto-adds missing columns via `ALTER TABLE ADD COLUMN` (no Alembic). Columns that are non-nullable with no default are skipped.
|
||||
- **UserSettings is a singleton**: always id=1, auto-created on first `GET`.
|
||||
- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`.
|
||||
- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint.
|
||||
- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB.
|
||||
- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`).
|
||||
- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null".
|
||||
- **JWT authentication** — mandatory for all `/api/*` routes except `/api/auth/*` and `/health`. Default password: `elysia`. Login via `POST /api/auth/login`. Token key in localStorage: `elysia_auth_token`. Frontend axios interceptor auto-attaches `Authorization: Bearer <token>`.
|
||||
- **UserSettings is a singleton**: always id=1, auto-created on first `GET`. `set_default_password()` auto-initializes password to `"elysia"` on first access if `password_hash` is empty.
|
||||
- **Account balance changes** auto-create `AccountHistory` records in `update_balance()`. You cannot directly modify balance via `PUT /api/accounts/{id}` — balance is replaced via `POST /api/accounts/{id}/balance`.
|
||||
- **Habit checkins for the same day** accumulate count (not new rows), enforced by a `(habit_id, checkin_date)` unique constraint. Cancelling reduces count; deleting when count ≤ 0 removes the row.
|
||||
- **Anniversaries / DebtInstallments** have computed fields (`next_date`, `days_until`, `year_count` / `remaining_periods`) calculated at request time, not stored in DB. The calculation functions live in the router layer (not models), because they depend on `date.today()`.
|
||||
- **`task_tags` M2M table** is defined in `models/tag.py` (not `models/task.py`). Tags only support create/delete (no update).
|
||||
- **Update schemas** use `clearable_fields` + `exclude_unset=True` to distinguish "field not sent" from "field sent as null". For non-clearable fields, `None` means "don't change"; for clearable fields, `None` means "clear it". See `schemas/task.py:TaskUpdate`, `schemas/habit.py:HabitUpdate`, `schemas/anniversary.py:AnniversaryUpdate`.
|
||||
- **JWT authentication** — `utils/auth.py` handles JWT (HS256, key: `"elysia-todo-secret-key-change-in-production"`, 24h expiry). Middleware in `main.py` validates tokens for all `/api/*` routes except `/api/auth/*` and `/health`. Default password: `elysia`. Login via `POST /api/auth/login`. Token key in localStorage: `elysia_auth_token`. **Middleware only validates the token; it does NOT inject user info into `request.state`.** Routes needing user data must call `get_current_user(request)` manually.
|
||||
- **Different cascade delete strategies:**
|
||||
- `Category`: refuses deletion if tasks are linked (400)
|
||||
- `HabitGroup`: sets linked habits' `group_id` to NULL
|
||||
- `AnniversaryCategory`: sets linked anniversaries' `category_id` to NULL
|
||||
- `FinancialAccount`: full cascade delete (removes linked history + installments)
|
||||
|
||||
### Router registration quirks
|
||||
- **`/health` MUST be registered before `/{full_path:path}`** in `main.py:114` — otherwise SPA fallback intercepts health checks and returns `index.html`.
|
||||
- **habits router** (`routers/habits.py`) is an empty-shell router that combines 3 sub-routers via `include_router`: habit-groups (`/api/habit-groups`), habits (`/api/habits`), and checkins (`/api/habits/{habit_id}/checkins`).
|
||||
- **anniversaries and accounts routers** BOTH use `prefix="/api"` (not `/api/anniversaries` or `/api/accounts`). Their internal paths are `/anniversaries`, `/anniversary-categories`, `/accounts`, `/debt-installments`, etc. They coexist because internal paths don't overlap — but be careful adding new routes; the first `include_router` match wins.
|
||||
|
||||
### Frontend (`WebUI/`)
|
||||
- Vue Router uses `createWebHistory()` (HTML5 history mode) — **requires the backend SPA fallback** (`/{full_path:path}` → `index.html`).
|
||||
- Vite dev proxy forwards `/api` → `http://localhost:23994`.
|
||||
- `@` alias maps to `src/`.
|
||||
- Global styles in SCSS (`src/styles/`).
|
||||
- 8 Pinia stores; Element Plus icons registered globally in `main.ts`.
|
||||
- 9 Pinia stores: `auth`, `task`, `category`, `tag`, `habit`, `anniversary`, `account`, `userSettings`, `ui`. The `ui` store manages dialog visibility, editing state, sidebar collapse, and global loading (no API calls).
|
||||
- Element Plus icons registered globally in `main.ts` — use `<Edit />`, `<Delete />` etc. in templates without imports.
|
||||
- Element Plus uses Chinese locale (`zh-cn`).
|
||||
|
||||
### Route registration order matters
|
||||
The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA fallback catch-all, otherwise `/health` requests return `index.html`. This is enforced in `api/app/main.py:114` — do not reorder these registrations.
|
||||
- **Vite 7.x + TypeScript 5.9** with `erasableSyntaxOnly: true` and project references.
|
||||
- **Frontend task filtering is entirely client-side.** `fetchTasks()` loads all tasks once; filtering, sorting, and pinyin search run in computed getters. No server-side filtering for tasks.
|
||||
- **Pinyin search** via `pinyin-pro` (`utils/pinyin.ts`) — supports Chinese character search by pinyin initials or full pinyin.
|
||||
- **Token key `elysia_auth_token` is hardcoded in 3 separate files** (`router/index.ts`, `api/request.ts`, `stores/useAuthStore.ts`). If you rename it, update all 3.
|
||||
- **401 response triggers a hard page redirect** (`window.location.href = '/login'`) in the axios interceptor, which reloads the SPA entirely. This differs from the router guard's in-app redirect (`return { path: '/login' }`).
|
||||
- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — intentional.
|
||||
|
||||
### Docker
|
||||
- `Dockerfile` copies pre-built `api/webui/` — you must build frontend before `docker build`.
|
||||
@@ -76,6 +89,6 @@ The `/health` endpoint must be registered **before** the `/{full_path:path}` SPA
|
||||
- No database migrations framework (Alembic)
|
||||
|
||||
## Additional notes
|
||||
- Swagger UI at `/docs` when backend is running — the live, auto-generated API reference.
|
||||
- Swagger UI at `/docs` when backend is running.
|
||||
- `python-multipart` in `requirements.txt` is required for FastAPI to parse form data (not optional).
|
||||
- `sass` is a runtime `dependency` (not `devDependency`) in `package.json` — unusual, but intentional.
|
||||
- `pydantic-settings` is installed but unused — config is hardcoded.
|
||||
|
||||
@@ -390,6 +390,11 @@ def update_installment(installment_id: int, data: DebtInstallmentUpdate, db: Ses
|
||||
installment = get_or_404(db, DebtInstallment, installment_id, "分期计划")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
if "account_id" in update_data:
|
||||
account = get_or_404(db, FinancialAccount, update_data["account_id"], "账户")
|
||||
if account.account_type != "debt":
|
||||
raise HTTPException(status_code=400, detail="分期计划只能关联欠款类型的账户")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(installment, field, value)
|
||||
|
||||
|
||||
@@ -219,10 +219,10 @@ def create_anniversary(data: AnniversaryCreate, db: Session = Depends(get_db)):
|
||||
db.refresh(db_anniversary)
|
||||
|
||||
today = date.today()
|
||||
next_date, days_until, year_count = compute_next_info(db_anniversary, today)
|
||||
result = enrich_anniversary(db_anniversary, today)
|
||||
|
||||
logger.info(f"创建纪念日成功: id={db_anniversary.id}, title={db_anniversary.title}")
|
||||
return db_anniversary
|
||||
return result
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"创建纪念日失败: {str(e)}")
|
||||
@@ -234,7 +234,9 @@ def get_anniversary(anniversary_id: int, db: Session = Depends(get_db)):
|
||||
"""获取单个纪念日"""
|
||||
try:
|
||||
anniversary = get_or_404(db, Anniversary, anniversary_id, "纪念日")
|
||||
return anniversary
|
||||
today = date.today()
|
||||
result = enrich_anniversary(anniversary, today)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user