Files
personal-progress-monitor/app/models.py
MobKBK 02b81f0113 feat: harden auth for public deploy + gantt, duration units, docker packaging
Security (fail-secure for production):
- Refuse to start without PPM_PASSWORD/PPM_PASSWORD_HASH unless
  PPM_ALLOW_NO_AUTH=1 (local dev only)
- Add login rate limiting (5 failures / 60s per client), enforced inside
  SessionManager.login so it cannot be bypassed at the endpoint layer
- compose defaults PPM_COOKIE_SECURE=true for HTTPS behind reverse proxy
- Add .dockerignore to keep runtime data/out of the image

Features:
- Task timeline gantt on home page (week/month zoom, horizontal scroll,
  infinite-arrow for open-ended dates, CSS Grid layout)
- Duration metrics gain a second/minute/hour unit for input & display
  (canonical storage stays integer seconds)
- Per-metric progress bars on home page (no more merged average)
- Backfill historical records via date picker in the today-record card
- Admin panel: stats overview, JSON import/restore, full task list mgmt

Fixes:
- Nullable task startDate; summarize handles missing start date
- Task dialog cancel/close no longer submits the form
- Import validates whole backup first, rejects path traversal, rebuilds
  archives, and replaces stale files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-24 12:11:03 +08:00

124 lines
3.7 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