This commit is contained in:
+8
-1
@@ -468,7 +468,12 @@ async def create_task(
|
|||||||
)
|
)
|
||||||
if parent is None:
|
if parent is None:
|
||||||
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务")
|
||||||
data = payload.model_dump()
|
if payload.rrule:
|
||||||
|
if not payload.due_at:
|
||||||
|
raise HTTPException(status_code=422, detail="重复任务需要截止时间")
|
||||||
|
from .mvp import parse_rrule
|
||||||
|
parse_rrule(payload.rrule)
|
||||||
|
data = payload.model_dump(exclude={"rrule"})
|
||||||
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None)
|
||||||
max_position = await db.scalar(select(func.max(Task.position)).where(
|
max_position = await db.scalar(select(func.max(Task.position)).where(
|
||||||
Task.user_id == user.id,
|
Task.user_id == user.id,
|
||||||
@@ -479,6 +484,8 @@ async def create_task(
|
|||||||
task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data)
|
task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data)
|
||||||
db.add(task)
|
db.add(task)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
if payload.rrule:
|
||||||
|
db.add(RecurrenceTemplate(user_id=user.id, task_id=task.id, rrule=payload.rrule.upper(), starts_at=task.due_at))
|
||||||
audit(db, user.id, "create", "task", task.id)
|
audit(db, user.id, "create", "task", task.id)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(task)
|
await db.refresh(task)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class TaskCreate(BaseModel):
|
|||||||
priority: int = Field(default=0, ge=0, le=3)
|
priority: int = Field(default=0, ge=0, le=3)
|
||||||
due_at: datetime | None = None
|
due_at: datetime | None = None
|
||||||
parent_id: UUID | None = None
|
parent_id: UUID | None = None
|
||||||
|
rrule: str | None = Field(default=None, min_length=5, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
|
|||||||
@@ -145,14 +145,15 @@ async function submitTaskCompose() {
|
|||||||
const taskTitle = composeTitle.value.trim()
|
const taskTitle = composeTitle.value.trim()
|
||||||
if (!taskTitle || !composeListId.value) return
|
if (!taskTitle || !composeListId.value) return
|
||||||
try {
|
try {
|
||||||
|
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
|
||||||
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
|
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
|
||||||
title: taskTitle,
|
title: taskTitle,
|
||||||
list_id: composeListId.value,
|
list_id: composeListId.value,
|
||||||
due_at: fromDateTimeLocal(composeDueAt.value),
|
due_at: fromDateTimeLocal(composeDueAt.value),
|
||||||
priority: composePriority.value,
|
priority: composePriority.value,
|
||||||
description: composeDescription.value,
|
description: composeDescription.value,
|
||||||
|
rrule,
|
||||||
}) })
|
}) })
|
||||||
if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value, composeRepeatConfig.value) }) })
|
|
||||||
if (isTaskView(activeView.value)) {
|
if (isTaskView(activeView.value)) {
|
||||||
tasks.value.push(task)
|
tasks.value.push(task)
|
||||||
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
|
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(app).toContain('const selectedTaskRepeat = ref')
|
expect(app).toContain('const selectedTaskRepeat = ref')
|
||||||
expect(app).toContain('重复<select v-model="composeRepeat"')
|
expect(app).toContain('重复<select v-model="composeRepeat"')
|
||||||
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
|
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
|
||||||
expect(app).toContain("api('/recurrences'")
|
expect(app).toContain('rrule,')
|
||||||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||||||
expect(app).toContain('<option value="custom">自定义…</option>')
|
expect(app).toContain('<option value="custom">自定义…</option>')
|
||||||
expect(app).toContain('class="repeat-custom-fields"')
|
expect(app).toContain('class="repeat-custom-fields"')
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ def test_bootstrap_returns_navigation_and_current_user(client):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_creating_task_with_recurrence_is_atomic(client):
|
||||||
|
inbox = boot(client)
|
||||||
|
created = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={
|
||||||
|
"title": "隔周复盘",
|
||||||
|
"list_id": inbox["id"],
|
||||||
|
"due_at": "2026-09-07T09:00:00Z",
|
||||||
|
"rrule": "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
recurrence = client.get(f"/api/v1/tasks/{created.json()['id']}/recurrence").json()
|
||||||
|
assert recurrence["rrule"] == "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR"
|
||||||
|
|
||||||
|
rejected = client.post(
|
||||||
|
"/api/v1/tasks",
|
||||||
|
json={"title": "缺少日期", "list_id": inbox["id"], "rrule": "FREQ=DAILY"},
|
||||||
|
)
|
||||||
|
assert rejected.status_code == 422
|
||||||
|
assert client.get("/api/v1/tasks", params={"q": "缺少日期"}).json()["items"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_custom_recurrence_rejects_invalid_weekdays_month_days_and_until(client):
|
def test_custom_recurrence_rejects_invalid_weekdays_month_days_and_until(client):
|
||||||
inbox = boot(client)
|
inbox = boot(client)
|
||||||
task = client.post(
|
task = client.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user