fix: harden countdown validation and recurrence
This commit is contained in:
+117
-35
@@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field, model_validator
|
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
|
||||||
from sqlalchemy import case, delete, func, select, update
|
from sqlalchemy import case, delete, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -325,11 +325,19 @@ class CountdownInput(BaseModel):
|
|||||||
calendar_mode: str = Field("solar", pattern="^(solar|lunar)$")
|
calendar_mode: str = Field("solar", pattern="^(solar|lunar)$")
|
||||||
lunar_month: int | None = None
|
lunar_month: int | None = None
|
||||||
lunar_day: int | None = None
|
lunar_day: int | None = None
|
||||||
ignore_year: bool = False
|
ignore_year: StrictBool = False
|
||||||
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
|
kind: str = Field("countdown", pattern="^(countdown|anniversary|birthday)$")
|
||||||
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
|
repeat_rule: str = Field("none", pattern="^(none|weekly|monthly|yearly)$")
|
||||||
icon: str = Field("📅", min_length=1, max_length=32)
|
icon: str = Field("📅", min_length=1, max_length=32)
|
||||||
pinned: bool = False
|
pinned: StrictBool = False
|
||||||
|
|
||||||
|
@field_validator("title", "icon")
|
||||||
|
@classmethod
|
||||||
|
def non_blank_text(cls, value: str):
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("text cannot be blank")
|
||||||
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def calendar_fields_valid(self):
|
def calendar_fields_valid(self):
|
||||||
@@ -356,10 +364,21 @@ class CountdownUpdate(BaseModel):
|
|||||||
calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$")
|
calendar_mode: str | None = Field(None, pattern="^(solar|lunar)$")
|
||||||
lunar_month: int | None = None
|
lunar_month: int | None = None
|
||||||
lunar_day: int | None = None
|
lunar_day: int | None = None
|
||||||
ignore_year: bool | None = None
|
ignore_year: StrictBool | None = None
|
||||||
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
|
kind: str | None = Field(None, pattern="^(countdown|anniversary|birthday)$")
|
||||||
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
|
repeat_rule: str | None = Field(None, pattern="^(none|weekly|monthly|yearly)$")
|
||||||
icon: str | None = Field(None, min_length=1, max_length=32)
|
icon: str | None = Field(None, min_length=1, max_length=32)
|
||||||
|
expected_updated_at: datetime | None = None
|
||||||
|
|
||||||
|
@field_validator("title", "icon")
|
||||||
|
@classmethod
|
||||||
|
def non_blank_text(cls, value: str | None):
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("text cannot be blank")
|
||||||
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def reject_explicit_nulls(self):
|
def reject_explicit_nulls(self):
|
||||||
@@ -375,10 +394,10 @@ class CountdownUpdate(BaseModel):
|
|||||||
|
|
||||||
def countdown_dict(row: Countdown, today: date | None = None):
|
def countdown_dict(row: Countdown, today: date | None = None):
|
||||||
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
today = today or datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||||
if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule != "none"):
|
if row.calendar_mode == "lunar" and (row.ignore_year or row.repeat_rule == "yearly"):
|
||||||
# 农历按年重复:忽略年份或指定重复时,都按“每年农历”语义计算下一次
|
search_from = max(today, row.event_date) if not row.ignore_year else today
|
||||||
display_date = next_lunar_occurrence(
|
display_date = next_lunar_occurrence(
|
||||||
row.lunar_month, row.lunar_day, True, "yearly", today
|
row.lunar_month, row.lunar_day, True, "yearly", search_from
|
||||||
) or row.event_date
|
) or row.event_date
|
||||||
effective_repeat = "yearly"
|
effective_repeat = "yearly"
|
||||||
else:
|
else:
|
||||||
@@ -392,7 +411,7 @@ def countdown_dict(row: Countdown, today: date | None = None):
|
|||||||
lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
|
lunar_year, _, _ = solar_to_lunar_parts(row.event_date)
|
||||||
lunar_text = (
|
lunar_text = (
|
||||||
solar_to_lunar_text(display_date)
|
solar_to_lunar_text(display_date)
|
||||||
if row.ignore_year or row.repeat_rule != "none"
|
if row.ignore_year or row.repeat_rule == "yearly"
|
||||||
else lunar_label_with_year(row.event_date)
|
else lunar_label_with_year(row.event_date)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -452,8 +471,25 @@ async def list_countdowns(archived: bool = False, user: User = Depends(current_u
|
|||||||
|
|
||||||
@router.patch("/countdowns/{countdown_id}")
|
@router.patch("/countdowns/{countdown_id}")
|
||||||
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def edit_countdown(countdown_id: UUID, payload: CountdownUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
row = await owned_countdown(db, user.id, countdown_id)
|
row = await db.scalar(
|
||||||
|
select(Countdown)
|
||||||
|
.where(Countdown.id == countdown_id, Countdown.user_id == user.id)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "倒数日不存在")
|
||||||
|
if row.archived_at is not None:
|
||||||
|
raise HTTPException(409, "请先恢复倒数日再编辑")
|
||||||
values = payload.model_dump(exclude_unset=True)
|
values = payload.model_dump(exclude_unset=True)
|
||||||
|
expected_updated_at = values.pop("expected_updated_at", None)
|
||||||
|
if expected_updated_at is not None:
|
||||||
|
actual_updated_at = row.updated_at
|
||||||
|
if actual_updated_at.tzinfo is None and expected_updated_at.tzinfo is not None:
|
||||||
|
actual_updated_at = actual_updated_at.replace(tzinfo=UTC)
|
||||||
|
if expected_updated_at.tzinfo is None and actual_updated_at.tzinfo is not None:
|
||||||
|
expected_updated_at = expected_updated_at.replace(tzinfo=UTC)
|
||||||
|
if actual_updated_at != expected_updated_at:
|
||||||
|
raise HTTPException(409, "倒数日已被其他操作更新,请刷新后重试")
|
||||||
combined = {
|
combined = {
|
||||||
"event_date": values.get("event_date", row.event_date),
|
"event_date": values.get("event_date", row.event_date),
|
||||||
"calendar_mode": values.get("calendar_mode", row.calendar_mode),
|
"calendar_mode": values.get("calendar_mode", row.calendar_mode),
|
||||||
@@ -899,10 +935,74 @@ async def restore_csv(
|
|||||||
return await restore_json(payload, mode, user, db)
|
return await restore_json(payload, mode, user, db)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_backup_datetime(value, field_name: str):
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise HTTPException(422, f"无效的倒数日备份字段:{field_name}")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(422, f"无效的倒数日备份字段:{field_name}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_countdown_backups(payload: dict) -> list[dict]:
|
||||||
|
raw_items = payload.get("countdowns", [])
|
||||||
|
if not isinstance(raw_items, list):
|
||||||
|
raise HTTPException(422, "倒数日备份必须是数组")
|
||||||
|
parsed = []
|
||||||
|
seen_ids = set()
|
||||||
|
for raw in raw_items:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise HTTPException(422, "无效的倒数日备份数据")
|
||||||
|
try:
|
||||||
|
source_id = UUID(raw["id"])
|
||||||
|
if source_id in seen_ids:
|
||||||
|
raise HTTPException(422, "倒数日备份包含重复 ID")
|
||||||
|
seen_ids.add(source_id)
|
||||||
|
raw_event_date = date.fromisoformat(raw["event_date"])
|
||||||
|
calendar_mode = raw.get("calendar_mode", "solar")
|
||||||
|
lunar_month = raw.get("lunar_month")
|
||||||
|
lunar_day = raw.get("lunar_day")
|
||||||
|
lunar_year = None
|
||||||
|
validation_date = raw_event_date
|
||||||
|
if calendar_mode == "lunar":
|
||||||
|
lunar_year, actual_month, actual_day = solar_to_lunar_parts(raw_event_date)
|
||||||
|
if (actual_month, actual_day) != (lunar_month, lunar_day):
|
||||||
|
raise HTTPException(422, "倒数日备份中的公历与农历日期不一致")
|
||||||
|
validation_date = date(lunar_year, 1, 1)
|
||||||
|
item = CountdownInput(
|
||||||
|
title=raw["title"],
|
||||||
|
event_date=validation_date,
|
||||||
|
calendar_mode=calendar_mode,
|
||||||
|
lunar_month=lunar_month,
|
||||||
|
lunar_day=lunar_day,
|
||||||
|
ignore_year=raw.get("ignore_year", False),
|
||||||
|
kind=raw.get("kind", "countdown"),
|
||||||
|
repeat_rule=raw.get("repeat_rule", "none"),
|
||||||
|
icon=raw.get("icon", "📅"),
|
||||||
|
pinned=raw.get("pinned", False),
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(422, "无效的倒数日备份数据") from exc
|
||||||
|
item.event_date = raw_event_date
|
||||||
|
parsed.append({
|
||||||
|
"source_id": source_id,
|
||||||
|
"item": item,
|
||||||
|
"archived_at": _parse_backup_datetime(raw.get("archived_at"), "archived_at"),
|
||||||
|
"created_at": _parse_backup_datetime(raw.get("created_at"), "created_at") or utcnow(),
|
||||||
|
"updated_at": _parse_backup_datetime(raw.get("updated_at"), "updated_at") or utcnow(),
|
||||||
|
})
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@router.post("/restore")
|
@router.post("/restore")
|
||||||
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||||
if payload.get("version") != 1:
|
if payload.get("version") != 1:
|
||||||
raise HTTPException(422, "不支持的备份版本")
|
raise HTTPException(422, "不支持的备份版本")
|
||||||
|
parsed_countdowns = _validate_countdown_backups(payload)
|
||||||
if mode == "replace":
|
if mode == "replace":
|
||||||
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
|
await db.execute(delete(Countdown).where(Countdown.user_id == user.id))
|
||||||
await db.execute(delete(Task).where(Task.user_id == user.id))
|
await db.execute(delete(Task).where(Task.user_id == user.id))
|
||||||
@@ -992,41 +1092,22 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
|
existing_countdown_ids = set((await db.scalars(select(Countdown.id).where(Countdown.user_id == user.id))).all())
|
||||||
occupied_countdown_ids = dict((await db.execute(select(Countdown.id, Countdown.user_id))).all())
|
occupied_countdown_ids = dict((await db.execute(select(Countdown.id, Countdown.user_id))).all())
|
||||||
has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True))))
|
has_pinned_countdown = bool(await db.scalar(select(Countdown.id).where(Countdown.user_id == user.id, Countdown.pinned.is_(True))))
|
||||||
for raw in payload.get("countdowns", []):
|
for parsed in parsed_countdowns:
|
||||||
try:
|
source_id = parsed["source_id"]
|
||||||
source_id = UUID(raw["id"])
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise HTTPException(422, "无效的倒数日备份 ID") from exc
|
|
||||||
row_id = source_id
|
row_id = source_id
|
||||||
while row_id in occupied_countdown_ids and occupied_countdown_ids[row_id] != user.id:
|
while row_id in occupied_countdown_ids and occupied_countdown_ids[row_id] != user.id:
|
||||||
row_id = uuid5(user.id, str(row_id))
|
row_id = uuid5(user.id, str(row_id))
|
||||||
if mode == "merge" and row_id in existing_countdown_ids:
|
if mode == "merge" and row_id in existing_countdown_ids:
|
||||||
continue
|
continue
|
||||||
try:
|
item = parsed["item"]
|
||||||
item = CountdownInput(
|
item.pinned = item.pinned and not has_pinned_countdown
|
||||||
title=raw["title"],
|
|
||||||
event_date=date.fromisoformat(raw["event_date"]),
|
|
||||||
calendar_mode=raw.get("calendar_mode", "solar"),
|
|
||||||
lunar_month=raw.get("lunar_month"),
|
|
||||||
lunar_day=raw.get("lunar_day"),
|
|
||||||
ignore_year=bool(raw.get("ignore_year", False)),
|
|
||||||
kind=raw.get("kind", "countdown"),
|
|
||||||
repeat_rule=raw.get("repeat_rule", "none"),
|
|
||||||
icon=raw.get("icon", "📅"),
|
|
||||||
pinned=bool(raw.get("pinned", False)) and not has_pinned_countdown,
|
|
||||||
)
|
|
||||||
# Backups store the canonical solar anchor; validation above converts
|
|
||||||
# lunar input again, so preserve the exact exported anchor on restore.
|
|
||||||
item.event_date = date.fromisoformat(raw["event_date"])
|
|
||||||
except (KeyError, TypeError, ValueError) as exc:
|
|
||||||
raise HTTPException(422, "无效的倒数日备份数据") from exc
|
|
||||||
row = Countdown(
|
row = Countdown(
|
||||||
id=row_id,
|
id=row_id,
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
**item.model_dump(),
|
**item.model_dump(),
|
||||||
archived_at=datetime.fromisoformat(raw["archived_at"]) if raw.get("archived_at") else None,
|
archived_at=parsed["archived_at"],
|
||||||
created_at=datetime.fromisoformat(raw["created_at"]) if raw.get("created_at") else utcnow(),
|
created_at=parsed["created_at"],
|
||||||
updated_at=datetime.fromisoformat(raw["updated_at"]) if raw.get("updated_at") else utcnow(),
|
updated_at=parsed["updated_at"],
|
||||||
)
|
)
|
||||||
if row.archived_at is not None:
|
if row.archived_at is not None:
|
||||||
row.pinned = False
|
row.pinned = False
|
||||||
@@ -1034,6 +1115,7 @@ async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merg
|
|||||||
db.add(row)
|
db.add(row)
|
||||||
existing_countdown_ids.add(row_id)
|
existing_countdown_ids.add(row_id)
|
||||||
occupied_countdown_ids[row_id] = user.id
|
occupied_countdown_ids[row_id] = user.id
|
||||||
|
restored += 1
|
||||||
audit(db, user.id, "restore", "backup", count=restored, mode=mode)
|
audit(db, user.id, "restore", "backup", count=restored, mode=mode)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"restored": restored, "mode": mode}
|
return {"restored": restored, "mode": mode}
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ describe('countdown modal accessibility', () => {
|
|||||||
expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])")
|
expect(source).not.toContain("Promise.all([request('/countdowns'),request('/countdowns?archived=true')])")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('prevents duplicate submits and sends the edit precondition', () => {
|
||||||
|
expect(source).toContain('if (busy.value) return')
|
||||||
|
expect(source).toContain('payload.expected_updated_at=editingItem.value?.updated_at')
|
||||||
|
expect(source).toContain(':disabled="busy"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps weekly and monthly recurrence available for lunar dates', () => {
|
||||||
|
expect(source).toContain('<option value="weekly">每周</option>')
|
||||||
|
expect(source).toContain('<option value="monthly">每月</option>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not repeat the lunar label in the converted date line', () => {
|
||||||
|
expect(source).toContain("return `${formatDate(item.display_date)}${suffix}`")
|
||||||
|
expect(source).not.toContain("`${formatDate(item.display_date)} · ${item.lunar_text}${suffix}`")
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps the empty state directly actionable', () => {
|
it('keeps the empty state directly actionable', () => {
|
||||||
expect(source).toContain('添加第一个重要日子')
|
expect(source).toContain('添加第一个重要日子')
|
||||||
expect(source).toContain('@click="openFromEmpty"')
|
expect(source).toContain('@click="openFromEmpty"')
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ type Countdown = {
|
|||||||
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
|
||||||
repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number
|
repeat_rule: 'none'|'weekly'|'monthly'|'yearly'; icon: string; pinned: boolean; archived_at: string|null; days: number
|
||||||
calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null
|
calendar_mode: 'solar'|'lunar'; lunar_year: number|null; lunar_month: number|null; lunar_day: number|null
|
||||||
ignore_year: boolean; lunar_text: string|null
|
ignore_year: boolean; lunar_text: string|null; updated_at: string
|
||||||
}
|
}
|
||||||
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
|
type Form = { title:string; event_date:string; kind:Countdown['kind']; repeat_rule:Countdown['repeat_rule']; calendar_mode:Countdown['calendar_mode']; lunar_year:number; lunar_month:number; lunar_day:number; leap_month:boolean; ignore_year:boolean }
|
||||||
type CountdownGroup = { key: string; title: string; items: Countdown[] }
|
type CountdownGroup = { key: string; title: string; items: Countdown[] }
|
||||||
const emit = defineEmits<{ notice: [message: string] }>()
|
const emit = defineEmits<{ notice: [message: string] }>()
|
||||||
const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
|
const items = ref<Countdown[]>([]), archived = ref<Countdown[]>([])
|
||||||
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
const showArchived = ref(false), open = ref(false), busy = ref(false)
|
||||||
const editingId = ref<string|null>(null), error = ref('')
|
const editingId = ref<string|null>(null), editingItem = ref<Countdown|null>(null), error = ref('')
|
||||||
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
|
const detailItem = ref<Countdown|null>(null), showAdvanced = ref(false)
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
|
||||||
@@ -55,7 +55,7 @@ function primaryDate(item: Countdown) {
|
|||||||
function secondaryDate(item: Countdown) {
|
function secondaryDate(item: Countdown) {
|
||||||
if (item.calendar_mode === 'lunar' && item.lunar_text) {
|
if (item.calendar_mode === 'lunar' && item.lunar_text) {
|
||||||
const suffix = item.ignore_year ? ' · 每年农历' : ''
|
const suffix = item.ignore_year ? ' · 每年农历' : ''
|
||||||
return `${formatDate(item.display_date)} · ${item.lunar_text}${suffix}`
|
return `${formatDate(item.display_date)}${suffix}`
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -122,6 +122,7 @@ async function load() {
|
|||||||
function edit(item:Countdown) {
|
function edit(item:Countdown) {
|
||||||
detailItem.value=null
|
detailItem.value=null
|
||||||
editingId.value=item.id
|
editingId.value=item.id
|
||||||
|
editingItem.value=item
|
||||||
showAdvanced.value=false
|
showAdvanced.value=false
|
||||||
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
|
||||||
open.value=true
|
open.value=true
|
||||||
@@ -132,9 +133,11 @@ function applyKindDefaults() {
|
|||||||
if (form.value.kind === 'countdown') form.value.repeat_rule='none'
|
if (form.value.kind === 'countdown') form.value.repeat_rule='none'
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (busy.value) return
|
||||||
if (!form.value.title.trim()) return
|
if (!form.value.title.trim()) return
|
||||||
await safe(async()=>{
|
await safe(async()=>{
|
||||||
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
const payload:any={ title:form.value.title.trim(), event_date:form.value.calendar_mode==='lunar' ? `${form.value.lunar_year}-01-01` : form.value.event_date, kind:form.value.kind, repeat_rule:form.value.ignore_year ? 'yearly' : form.value.repeat_rule, calendar_mode:form.value.calendar_mode, ignore_year:form.value.ignore_year }
|
||||||
|
if (editingId.value) payload.expected_updated_at=editingItem.value?.updated_at
|
||||||
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
|
||||||
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
|
||||||
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
|
||||||
@@ -162,7 +165,7 @@ function trapDetailFocus(event: KeyboardEvent) {
|
|||||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
|
||||||
}
|
}
|
||||||
function openFromEmpty(){openCountdownComposer()}
|
function openFromEmpty(){openCountdownComposer()}
|
||||||
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
function openCountdownComposer(origin?: { x: number; y: number }){if(origin)composerOrigin.value=origin;detailItem.value=null;editingId.value=null;editingItem.value=null;showAdvanced.value=false;form.value=freshForm();open.value=true;focusDialog()}
|
||||||
defineExpose({ openCountdownComposer })
|
defineExpose({ openCountdownComposer })
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
onBeforeUnmount(() => { previousFocus = null })
|
onBeforeUnmount(() => { previousFocus = null })
|
||||||
@@ -219,7 +222,7 @@ onBeforeUnmount(() => { previousFocus = null })
|
|||||||
<label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">,每年按农历计算</span></label>
|
<label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">,每年按农历计算</span></label>
|
||||||
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
<label>重复<select v-model="form.repeat_rule" :disabled="form.ignore_year"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
|
||||||
</details></div>
|
</details></div>
|
||||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer>
|
<footer class="app-sheet__footer"><button type="button" class="secondary" :disabled="busy" @click="closeDialog">取消</button><button class="primary-small" :disabled="busy">保存</button></footer>
|
||||||
</form></div>
|
</form></div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from copy import deepcopy
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from backend.lunar_support import (
|
from backend.lunar_support import (
|
||||||
@@ -265,3 +266,149 @@ def test_countdown_backup_merge_remaps_ids_owned_by_another_user(client):
|
|||||||
replaced = client.get("/api/v1/countdowns").json()
|
replaced = client.get("/api/v1/countdowns").json()
|
||||||
assert len(replaced) == 1
|
assert len(replaced) == 1
|
||||||
assert replaced[0]["id"] == items[0]["id"]
|
assert replaced[0]["id"] == items[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_countdown_rejects_blank_text_and_preserves_lunar_recurrence(client):
|
||||||
|
boot(client)
|
||||||
|
assert create_countdown(client, title=" ").status_code == 422
|
||||||
|
assert create_countdown(client, icon="\n\t").status_code == 422
|
||||||
|
lunar = {
|
||||||
|
"calendar_mode": "lunar",
|
||||||
|
"lunar_month": 8,
|
||||||
|
"lunar_day": 4,
|
||||||
|
"event_date": "2026-01-01",
|
||||||
|
}
|
||||||
|
weekly = create_countdown(client, repeat_rule="weekly", **lunar)
|
||||||
|
monthly = create_countdown(client, repeat_rule="monthly", **lunar)
|
||||||
|
assert weekly.status_code == 201
|
||||||
|
assert monthly.status_code == 201
|
||||||
|
assert weekly.json()["repeat_rule"] == "weekly"
|
||||||
|
assert monthly.json()["repeat_rule"] == "monthly"
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_fixed_year_lunar_countdown_does_not_occur_before_anchor(client):
|
||||||
|
boot(client)
|
||||||
|
created = create_countdown(
|
||||||
|
client,
|
||||||
|
title="未来农历事件",
|
||||||
|
event_date="2030-01-01",
|
||||||
|
calendar_mode="lunar",
|
||||||
|
lunar_month=1,
|
||||||
|
lunar_day=1,
|
||||||
|
repeat_rule="yearly",
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
item = created.json()
|
||||||
|
assert item["event_date"] == "2030-02-03"
|
||||||
|
assert item["display_date"] >= item["event_date"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_archived_countdown_cannot_be_edited(client):
|
||||||
|
boot(client)
|
||||||
|
item = create_countdown(client).json()
|
||||||
|
assert client.delete(f"/api/v1/countdowns/{item['id']}").status_code == 204
|
||||||
|
response = client.patch(f"/api/v1/countdowns/{item['id']}", json={"title": "归档后偷改"})
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_countdown_edit_is_rejected(client):
|
||||||
|
boot(client)
|
||||||
|
item = create_countdown(client).json()
|
||||||
|
first = client.patch(
|
||||||
|
f"/api/v1/countdowns/{item['id']}",
|
||||||
|
json={"title": "第一次修改", "expected_updated_at": item["updated_at"]},
|
||||||
|
)
|
||||||
|
assert first.status_code == 200
|
||||||
|
stale = client.patch(
|
||||||
|
f"/api/v1/countdowns/{item['id']}",
|
||||||
|
json={"title": "过期修改", "expected_updated_at": item["updated_at"]},
|
||||||
|
)
|
||||||
|
assert stale.status_code == 409
|
||||||
|
current = client.get("/api/v1/countdowns").json()[0]
|
||||||
|
assert current["title"] == "第一次修改"
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_rejects_malformed_countdowns_atomically(client):
|
||||||
|
boot(client)
|
||||||
|
original = create_countdown(client, title="必须保留").json()
|
||||||
|
exported = client.get("/api/v1/export").json()
|
||||||
|
|
||||||
|
malformed = deepcopy(exported)
|
||||||
|
malformed["countdowns"][0]["archived_at"] = "not-a-date"
|
||||||
|
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||||
|
|
||||||
|
for invalid in (None, 7):
|
||||||
|
malformed = deepcopy(exported)
|
||||||
|
malformed["countdowns"] = invalid
|
||||||
|
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=malformed)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||||
|
|
||||||
|
duplicate = deepcopy(exported)
|
||||||
|
duplicate["countdowns"].append(deepcopy(duplicate["countdowns"][0]))
|
||||||
|
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=duplicate)
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert [item["id"] for item in client.get("/api/v1/countdowns").json()] == [original["id"]]
|
||||||
|
|
||||||
|
two_pinned = deepcopy(exported)
|
||||||
|
extra = deepcopy(two_pinned["countdowns"][0])
|
||||||
|
extra["id"] = "34deeea0-d976-4581-b378-a66f28623de8"
|
||||||
|
extra["title"] = "第二个置顶"
|
||||||
|
two_pinned["countdowns"][0]["pinned"] = True
|
||||||
|
extra["pinned"] = True
|
||||||
|
two_pinned["countdowns"].append(extra)
|
||||||
|
response = client.post("/api/v1/restore", params={"mode": "replace"}, json=two_pinned)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert sum(item["pinned"] for item in client.get("/api/v1/countdowns").json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_validates_lunar_metadata_booleans_and_count(client):
|
||||||
|
boot(client)
|
||||||
|
item = create_countdown(
|
||||||
|
client,
|
||||||
|
title="农历备份",
|
||||||
|
event_date="2025-01-01",
|
||||||
|
calendar_mode="lunar",
|
||||||
|
lunar_month=8,
|
||||||
|
lunar_day=4,
|
||||||
|
).json()
|
||||||
|
exported = client.get("/api/v1/export").json()
|
||||||
|
|
||||||
|
contradictory = deepcopy(exported)
|
||||||
|
contradictory["countdowns"][0]["event_date"] = "2025-01-01"
|
||||||
|
assert client.post("/api/v1/restore", params={"mode": "replace"}, json=contradictory).status_code == 422
|
||||||
|
assert client.get("/api/v1/countdowns").json()[0]["id"] == item["id"]
|
||||||
|
|
||||||
|
string_booleans = deepcopy(exported)
|
||||||
|
string_booleans["countdowns"][0]["ignore_year"] = "false"
|
||||||
|
string_booleans["countdowns"][0]["pinned"] = "false"
|
||||||
|
assert client.post("/api/v1/restore", params={"mode": "replace"}, json=string_booleans).status_code == 422
|
||||||
|
|
||||||
|
clean = deepcopy(exported)
|
||||||
|
clean["countdowns"][0]["id"] = "13ad79f4-78bc-4a53-a952-e1da84be1a9a"
|
||||||
|
restored = client.post("/api/v1/restore", params={"mode": "merge"}, json=clean)
|
||||||
|
assert restored.status_code == 200
|
||||||
|
assert restored.json()["restored"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_restore_accepts_lunar_dates_whose_solar_anchor_is_in_next_year(client):
|
||||||
|
boot(client)
|
||||||
|
created = create_countdown(
|
||||||
|
client,
|
||||||
|
title="跨公历年农历日",
|
||||||
|
event_date="2001-01-01",
|
||||||
|
calendar_mode="lunar",
|
||||||
|
lunar_month=12,
|
||||||
|
lunar_day=20,
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["event_date"] == "2002-02-01"
|
||||||
|
exported = client.get("/api/v1/export").json()
|
||||||
|
|
||||||
|
restored = client.post("/api/v1/restore", params={"mode": "replace"}, json=exported)
|
||||||
|
assert restored.status_code == 200
|
||||||
|
item = client.get("/api/v1/countdowns").json()[0]
|
||||||
|
assert item["event_date"] == "2002-02-01"
|
||||||
|
assert item["lunar_year"] == 2001
|
||||||
|
|||||||
Reference in New Issue
Block a user