Files
personal-progress-monitor/app/models.py
MobKBK ff10e7f065 fix: 折线图纵轴优化——默认约80%头空间、取整到nice刻度,支持手动设置纵轴上限
- 纵轴自动刻度改为精细步进(1/1.5/2/2.5/3/4/5/6/8/10),数据最大值落在约80%处
- ChartInput 新增 yMax 字段(JSON 存储、可拓展),图表弹窗新增「纵轴上限」输入
- 新增 yMax 往返测试
2026-09-04 15:07:10 +08:00

181 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import date, datetime
from enum import Enum
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class MetricKind(str, Enum):
checkin = "checkin"
quantity = "quantity"
duration = "duration"
class MetricPeriod(str, Enum):
day = "day"
week = "week"
month = "month"
lifetime = "lifetime"
class DurationUnit(str, Enum):
second = "second"
minute = "minute"
hour = "hour"
class MetricInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
metricId: str | None = None
name: Annotated[str, Field(min_length=1, max_length=60)]
kind: MetricKind
period: MetricPeriod = MetricPeriod.day
target: Annotated[int | None, Field(ge=1)] = None
unit: Annotated[str, Field(max_length=20)] = ""
durationUnit: DurationUnit = DurationUnit.minute
@model_validator(mode="after")
def normalize_metric(self) -> "MetricInput":
if self.kind == MetricKind.checkin:
self.target = 1 if self.target is None else self.target
self.unit = self.unit or "次"
elif self.kind == MetricKind.duration:
# 时长统一以整数秒存储,durationUnit 仅是输入/显示口径。
self.unit = ""
return self
class TaskInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
name: Annotated[str, Field(min_length=1, max_length=120)]
description: Annotated[str, Field(max_length=2000)] = ""
startDate: date | None = None
endDate: date | None = None
priority: Annotated[int, Field(ge=1, le=5)] = 3
state: Literal["active", "paused"] = "active"
metrics: Annotated[list[MetricInput], Field(min_length=1, max_length=20)]
@model_validator(mode="after")
def validate_dates_and_metrics(self) -> "TaskInput":
if self.startDate and self.endDate and self.endDate < self.startDate:
raise ValueError("结束日期不能早于开始日期")
ids = [metric.metricId for metric in self.metrics if metric.metricId]
if len(ids) != len(set(ids)):
raise ValueError("metricId 不能重复")
return self
class TaskUpdate(TaskInput):
expectedVersion: Annotated[int, Field(ge=1)]
class ReorderInput(BaseModel):
model_config = ConfigDict(extra="forbid")
taskIds: list[str]
expectedVersion: Annotated[int, Field(ge=1)]
@field_validator("taskIds")
@classmethod
def unique_task_ids(cls, value: list[str]) -> list[str]:
if len(value) != len(set(value)):
raise ValueError("taskIds 不能重复")
return value
class MetricValue(BaseModel):
model_config = ConfigDict(extra="forbid")
metricId: str
value: Annotated[int, Field(ge=0)]
class DailyRecordInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
expectedVersion: Annotated[int | None, Field(ge=0)] = None
note: Annotated[str, Field(max_length=4000)] = ""
completedAt: datetime | None = None
values: list[MetricValue] = Field(default_factory=list)
@field_validator("values")
@classmethod
def unique_metric_values(cls, value: list[MetricValue]) -> list[MetricValue]:
ids = [item.metricId for item in value]
if len(ids) != len(set(ids)):
raise ValueError("同一指标只能提交一次")
return value
class LoginInput(BaseModel):
model_config = ConfigDict(extra="forbid")
password: Annotated[str, Field(min_length=1, max_length=500)]
class PurgeInput(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation: str
class ScheduleRecurrence(str, Enum):
none = "none"
daily = "daily"
weekly = "weekly"
monthly = "monthly"
class ScheduleInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: Annotated[str, Field(min_length=1, max_length=120)]
description: Annotated[str, Field(max_length=2000)] = ""
date: date
time: Annotated[str | None, Field(max_length=5)] = None
recurrence: ScheduleRecurrence = ScheduleRecurrence.none
priority: Annotated[int, Field(ge=1, le=5)] = 3
state: Literal["active", "archived"] = "active"
@field_validator("time")
@classmethod
def validate_time(cls, value: str | None) -> str | None:
if value is None or value == "":
return None
if len(value) != 5 or value[2] != ":" or not value[:2].isdigit() or not value[3:].isdigit():
raise ValueError("时间必须是 HH:MM 格式")
hour, minute = int(value[:2]), int(value[3:])
if hour > 23 or minute > 59:
raise ValueError("时间必须是合法的时分")
return value
class ScheduleUpdate(ScheduleInput):
expectedVersion: Annotated[int, Field(ge=1)]
class ScheduleCompleteInput(BaseModel):
model_config = ConfigDict(extra="forbid")
date: date
completed: bool = True
expectedVersion: Annotated[int, Field(ge=1)]
class ChartInput(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
title: Annotated[str, Field(min_length=1, max_length=80)]
taskId: str | None = None
metricId: str | None = None
days: Annotated[int, Field(ge=7, le=365)] = 30
yMax: Annotated[int | None, Field(ge=1)] = None
class ChartUpdate(ChartInput):
expectedVersion: Annotated[int, Field(ge=1)]