fix: harden habit input and lifecycle
This commit is contained in:
+117
-12
@@ -579,18 +579,74 @@ async def purge_countdown(countdown_id: UUID, user: User = Depends(current_user)
|
||||
class HabitCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: str = Field("boolean", pattern="^(boolean|numeric)$")
|
||||
target: float = Field(1, gt=0)
|
||||
max_value: float | None = Field(None, gt=0)
|
||||
target: float = Field(1, gt=0, allow_inf_nan=False)
|
||||
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
|
||||
schedule_type: str = Field("daily", pattern="^(daily|weekly|monthly|interval)$")
|
||||
weekdays: list[int] | None = None
|
||||
month_days: list[int] | None = None
|
||||
interval_days: int | None = Field(None, ge=1)
|
||||
start_date: date = Field(default_factory=date.today)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def clean_name(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("name cannot be blank")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def schedule_valid(self):
|
||||
if self.schedule_type == "interval" and not self.interval_days: raise ValueError("interval_days required")
|
||||
if self.kind == "boolean": self.target = 1; self.max_value = 1
|
||||
def values_valid(self):
|
||||
if self.schedule_type == "weekly":
|
||||
if not self.weekdays or len(self.weekdays) != len(set(self.weekdays)) or any(day < 0 or day > 6 for day in self.weekdays):
|
||||
raise ValueError("weekdays must be non-empty, unique, and between 0 and 6")
|
||||
elif self.schedule_type == "monthly":
|
||||
if not self.month_days or len(self.month_days) != len(set(self.month_days)) or any(day < 1 or day > 31 for day in self.month_days):
|
||||
raise ValueError("month_days must be non-empty, unique, and between 1 and 31")
|
||||
elif self.schedule_type == "interval" and self.interval_days is None:
|
||||
raise ValueError("interval_days required")
|
||||
|
||||
if self.schedule_type != "weekly":
|
||||
self.weekdays = None
|
||||
if self.schedule_type != "monthly":
|
||||
self.month_days = None
|
||||
if self.schedule_type != "interval":
|
||||
self.interval_days = None
|
||||
|
||||
if self.kind == "boolean":
|
||||
self.target = 1
|
||||
self.max_value = 1
|
||||
elif self.max_value is not None and self.max_value < self.target:
|
||||
raise ValueError("max_value must be greater than or equal to target")
|
||||
return self
|
||||
|
||||
|
||||
class HabitUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
kind: str | None = Field(None, pattern="^(boolean|numeric)$")
|
||||
target: float | None = Field(None, gt=0, allow_inf_nan=False)
|
||||
max_value: float | None = Field(None, gt=0, allow_inf_nan=False)
|
||||
schedule_type: str | None = Field(None, pattern="^(daily|weekly|monthly|interval)$")
|
||||
weekdays: list[int] | None = None
|
||||
month_days: list[int] | None = None
|
||||
interval_days: int | None = Field(None, ge=1)
|
||||
start_date: date | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def clean_name(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("name cannot be blank")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_nulls(self):
|
||||
for field in self.model_fields_set:
|
||||
if getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
return self
|
||||
|
||||
|
||||
@@ -606,10 +662,10 @@ class HabitReorder(BaseModel):
|
||||
|
||||
class HabitLogInput(BaseModel):
|
||||
day: date
|
||||
value: float = Field(gt=0)
|
||||
value: float = Field(gt=0, allow_inf_nan=False)
|
||||
|
||||
|
||||
class HabitLogEdit(BaseModel): value: float = Field(ge=0)
|
||||
class HabitLogEdit(BaseModel): value: float = Field(ge=0, allow_inf_nan=False)
|
||||
class PauseInput(BaseModel):
|
||||
start_date: date
|
||||
end_date: date
|
||||
@@ -629,6 +685,23 @@ async def owned_habit(db, user_id, habit_id):
|
||||
return row
|
||||
|
||||
|
||||
def require_active_habit(habit):
|
||||
if habit.archived_at is not None:
|
||||
raise HTTPException(409, "归档习惯不可修改")
|
||||
|
||||
|
||||
async def require_positive_log_day(db, habit, day):
|
||||
if not scheduled(habit, day):
|
||||
raise HTTPException(409, "非计划日不可记录正向进度")
|
||||
paused = await db.scalar(select(HabitPause.id).where(
|
||||
HabitPause.habit_id == habit.id,
|
||||
HabitPause.start_date <= day,
|
||||
HabitPause.end_date >= day,
|
||||
))
|
||||
if paused is not None:
|
||||
raise HTTPException(409, "暂停日不可记录正向进度")
|
||||
|
||||
|
||||
@router.post("/habits", status_code=201)
|
||||
async def create_habit(payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
max_position = await db.scalar(select(func.max(Habit.position)).where(Habit.user_id == user.id))
|
||||
@@ -662,11 +735,23 @@ async def reorder_habits(payload: HabitReorder, user: User = Depends(current_use
|
||||
|
||||
|
||||
@router.patch("/habits/{habit_id}")
|
||||
async def edit_habit(habit_id: UUID, payload: HabitCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
async def edit_habit(habit_id: UUID, payload: HabitUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_habit(db, user.id, habit_id)
|
||||
for key, value in payload.model_dump(exclude={"weekdays", "month_days"}).items(): setattr(row, key, value)
|
||||
row.weekdays = ",".join(map(str, payload.weekdays)) if payload.weekdays else None; row.month_days = ",".join(map(str, payload.month_days)) if payload.month_days else None
|
||||
await db.commit(); return habit_dict(row)
|
||||
require_active_habit(row)
|
||||
current = habit_dict(row)
|
||||
merged = {key: current[key] for key in HabitCreate.model_fields}
|
||||
merged.update(payload.model_dump(exclude_unset=True))
|
||||
try:
|
||||
validated = HabitCreate.model_validate(merged)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc)) from exc
|
||||
values = validated.model_dump(exclude={"weekdays", "month_days"})
|
||||
for key, value in values.items():
|
||||
setattr(row, key, value)
|
||||
row.weekdays = ",".join(map(str, validated.weekdays)) if validated.weekdays else None
|
||||
row.month_days = ",".join(map(str, validated.month_days)) if validated.month_days else None
|
||||
await db.commit()
|
||||
return habit_dict(row)
|
||||
|
||||
|
||||
@router.delete("/habits/{habit_id}", status_code=204)
|
||||
@@ -677,6 +762,8 @@ async def archive_habit(habit_id: UUID, user: User = Depends(current_user), db:
|
||||
@router.delete("/habits/{habit_id}/permanent", status_code=204)
|
||||
async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
row = await owned_habit(db, user.id, habit_id)
|
||||
if row.archived_at is None:
|
||||
raise HTTPException(409, "请先归档再永久删除")
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
@@ -685,6 +772,8 @@ async def delete_habit_permanently(habit_id: UUID, user: User = Depends(current_
|
||||
@router.post("/habits/{habit_id}/logs")
|
||||
async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
require_active_habit(habit)
|
||||
await require_positive_log_day(db, habit, payload.day)
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == payload.day))
|
||||
value = min((row.value if row else 0) + payload.value, habit.max_value or float("inf"))
|
||||
if habit.kind == "boolean": value = 1
|
||||
@@ -696,13 +785,27 @@ async def add_habit_log(habit_id: UUID, payload: HabitLogInput, user: User = Dep
|
||||
@router.put("/habits/{habit_id}/logs/{day}")
|
||||
async def edit_habit_log(habit_id: UUID, day: date, payload: HabitLogEdit, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
require_active_habit(habit)
|
||||
value = min(payload.value, habit.max_value or float("inf")); value = float(bool(value)) if habit.kind == "boolean" else value
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
|
||||
if value > (row.value if row else 0):
|
||||
await require_positive_log_day(db, habit, day)
|
||||
if row: row.value = value; row.updated_at = utcnow()
|
||||
else: row = HabitLog(habit_id=habit.id, day=day, value=value); db.add(row)
|
||||
await db.commit(); return {"day": row.day, "value": row.value}
|
||||
|
||||
|
||||
@router.delete("/habits/{habit_id}/logs/{day}", status_code=204)
|
||||
async def delete_habit_log(habit_id: UUID, day: date, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
require_active_habit(habit)
|
||||
row = await db.scalar(select(HabitLog).where(HabitLog.habit_id == habit.id, HabitLog.day == day))
|
||||
if row is not None:
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/habits/{habit_id}/logs")
|
||||
async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None, alias="from"), to_date: date | None = Query(default=None, alias="to"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
@@ -716,7 +819,9 @@ async def habit_logs(habit_id: UUID, from_date: date | None = Query(default=None
|
||||
|
||||
@router.post("/habits/{habit_id}/pauses", status_code=201)
|
||||
async def pause_habit(habit_id: UUID, payload: PauseInput, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
habit = await owned_habit(db, user.id, habit_id); row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
|
||||
habit = await owned_habit(db, user.id, habit_id)
|
||||
require_active_habit(habit)
|
||||
row = HabitPause(habit_id=habit.id, **payload.model_dump()); db.add(row); await db.commit(); await db.refresh(row); return {"id": row.id, **payload.model_dump()}
|
||||
|
||||
|
||||
def scheduled(h, day):
|
||||
|
||||
+28
-3
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class InitializeRequest(BaseModel):
|
||||
@@ -45,6 +45,14 @@ class SessionOut(BaseModel):
|
||||
class NameUpdate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def clean_name(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("name cannot be blank")
|
||||
return value
|
||||
|
||||
|
||||
class FolderCreate(NameUpdate):
|
||||
pass
|
||||
@@ -56,8 +64,7 @@ class FolderOut(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ListCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
class ListCreate(NameUpdate):
|
||||
folder_id: UUID | None = None
|
||||
|
||||
|
||||
@@ -80,6 +87,14 @@ class TaskCreate(BaseModel):
|
||||
parent_id: UUID | None = None
|
||||
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def clean_title(cls, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("title cannot be blank")
|
||||
return value
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
@@ -91,6 +106,16 @@ class TaskUpdate(BaseModel):
|
||||
list_id: UUID | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def clean_title(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("title cannot be blank")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_null_non_nullable_fields(self):
|
||||
for field in ("title", "description", "priority", "completed", "list_id", "due_has_time"):
|
||||
|
||||
Reference in New Issue
Block a user