Merge branch 'main' into feat/blocked-tasks-blocking

This commit is contained in:
Harsh Patel 2026-06-10 15:27:47 +05:30 committed by GitHub
commit 2b7d11b23c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 340 additions and 4 deletions

View File

@ -415,7 +415,7 @@ func (t *Task) UpdateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Lab
// LabelTaskBulk is a helper struct to update a bunch of labels at once
type LabelTaskBulk struct {
// All labels you want to update at once.
Labels []*Label `json:"labels"`
Labels []*Label `json:"labels" doc:"The complete set of labels the task should have after the call. Any label currently on the task that is not in this list is removed; any label in the list that is not yet on the task is added. You must be able to see every label you attach."`
TaskID int64 `json:"-" param:"projecttask"`
web.CRUDable `json:"-"`

View File

@ -33,9 +33,9 @@ const MinPositionSpacing = 0.01
type TaskPosition struct {
// The ID of the task this position is for
TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"`
TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."`
// The project view this task is related to
ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"`
ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id" doc:"The id of the project view this position applies to. Positions are stored per view, so the same task has an independent position in each of its project's views."`
// The position of the task - any task project can be sorted as usual by this parameter.
// When accessing tasks via kanban buckets, this is primarily used to sort them based on a range
// We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number).
@ -44,7 +44,7 @@ type TaskPosition struct {
// which also leaves a lot of room for rearranging and sorting later.
// Positions are always saved per view. They will automatically be set if you request the tasks through a view
// endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint.
Position float64 `xorm:"double not null" json:"position"`
Position float64 `xorm:"double not null" json:"position" doc:"The task's sort position within the view, as a float so a task can be placed between any two others. To drop a task between two neighbours, set this to their midpoint. Values below the minimum spacing trigger a server-side recalculation of all positions in the view, so the stored value may differ from what you sent."`
web.CRUDable `xorm:"-" json:"-"`
web.Permissions `xorm:"-" json:"-"`

View File

@ -0,0 +1,62 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package apiv2
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"
"github.com/danielgtaylor/huma/v2"
)
// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the
// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose
// CanCreate enforces write access to the task), but the verb is PUT because the
// operation replaces the task's whole label set — the idempotent PUT semantics
// describe it more honestly than POST.
func RegisterLabelTaskBulkRoutes(api huma.API) {
tags := []string{"labels"}
Register(api, huma.Operation{
OperationID: "task-labels-bulk-replace",
Summary: "Replace all labels on a task",
Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.",
Method: http.MethodPut,
Path: "/tasks/{projecttask}/labels/bulk",
Tags: tags,
}, labelTasksBulkReplace)
}
func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) }
func labelTasksBulkReplace(ctx context.Context, in *struct {
TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."`
Body models.LabelTaskBulk
}) (*singleBody[models.LabelTaskBulk], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
in.Body.TaskID = in.TaskID // parent from the path, not the body
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil
}

View File

@ -0,0 +1,63 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package apiv2
import (
"context"
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"
"github.com/danielgtaylor/huma/v2"
)
// RegisterTaskPositionRoutes wires the task-position update onto the Huma API.
//
// Setting a position is a plain CRUDable Update, so the handler reuses
// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only
// custom part is taking TaskID from the path rather than the request body.
func RegisterTaskPositionRoutes(api huma.API) {
tags := []string{"tasks"}
Register(api, huma.Operation{
OperationID: "tasks-position-update",
Summary: "Set a task's position in a view",
Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.",
Method: http.MethodPut,
Path: "/tasks/{task}/position",
Tags: tags,
}, tasksPositionUpdate)
}
func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) }
func tasksPositionUpdate(ctx context.Context, in *struct {
TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."`
Body models.TaskPosition
}) (*singleBody[models.TaskPosition], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
tp := &in.Body
tp.TaskID = in.TaskID // URL wins over body
if err := handler.DoUpdate(ctx, tp, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.TaskPosition]{Body: tp}, nil
}

View File

@ -0,0 +1,117 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"encoding/json"
"net/http"
"testing"
"code.vikunja.io/api/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix
// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk.
// The body is the full target label set; the call adds missing labels and
// removes any not listed.
//
// Permission topology for testuser1 (see pkg/db/fixtures):
// - task 1 (project 1): owned by user1 → write. Has label #4 attached.
// - task 15 (project 6): shared via team 2 read-only → no write.
// - task 16 (project 7): shared via team 3 with write.
// - task 34 (project 20): private to user13 → no access.
//
// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1.
func TestLabelTaskBulk_V2(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
put := func(taskID, body string) (*v2ProblemJSON, []int64, int) {
t.Helper()
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "")
if rec.Code >= 400 {
var p v2ProblemJSON
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String())
return &p, nil, rec.Code
}
var resp struct {
Labels []struct {
ID int64 `json:"id"`
} `json:"labels"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String())
ids := make([]int64, 0, len(resp.Labels))
for _, l := range resp.Labels {
ids = append(ids, l.ID)
}
return nil, ids, rec.Code
}
t.Run("Replace adds and removes", func(t *testing.T) {
// task 1 starts with label #4; replacing with [#1] must add #1 and drop #4.
p, ids, code := put("1", `{"labels":[{"id":1}]}`)
require.Nil(t, p)
assert.Equal(t, http.StatusOK, code)
assert.ElementsMatch(t, []int64{1}, ids,
"task 1's labels must be exactly {1} after replace")
})
t.Run("Empty list clears all labels", func(t *testing.T) {
// task 16 (write-shared) gets a label, then an empty replace removes it.
_, ids, code := put("16", `{"labels":[{"id":1}]}`)
assert.Equal(t, http.StatusOK, code)
assert.ElementsMatch(t, []int64{1}, ids)
p, ids, code := put("16", `{"labels":[]}`)
require.Nil(t, p)
assert.Equal(t, http.StatusOK, code)
assert.Empty(t, ids, "empty replace must remove every label")
})
t.Run("Write share can replace", func(t *testing.T) {
_, ids, code := put("16", `{"labels":[{"id":1}]}`)
assert.Equal(t, http.StatusOK, code)
assert.ElementsMatch(t, []int64{1}, ids)
})
t.Run("Read-only share is forbidden", func(t *testing.T) {
p, _, code := put("15", `{"labels":[{"id":1}]}`)
assert.Equal(t, http.StatusForbidden, code)
require.NotNil(t, p)
})
t.Run("Forbidden task", func(t *testing.T) {
// task 34 is private to user13.
p, _, code := put("34", `{"labels":[{"id":1}]}`)
assert.Equal(t, http.StatusForbidden, code)
require.NotNil(t, p)
})
t.Run("Nonexisting task", func(t *testing.T) {
p, _, code := put("9999", `{"labels":[{"id":1}]}`)
assert.Equal(t, http.StatusNotFound, code)
require.NotNil(t, p)
assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code)
})
t.Run("Label the user cannot see is rejected", func(t *testing.T) {
// label #3 (user2's, attached to no task user1 can see) is invisible to
// user1; attaching it to a writable task must be refused.
p, _, code := put("1", `{"labels":[{"id":3}]}`)
assert.Equal(t, http.StatusForbidden, code)
require.NotNil(t, p)
assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code)
})
}

View File

@ -0,0 +1,94 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"code.vikunja.io/api/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma
// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL
// only models base[/{id}] paths, not action sub-paths.
func TestTaskPositionV2(t *testing.T) {
t.Run("updates the position of a writable task", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1.
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resp models.TaskPosition
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL")
assert.Equal(t, int64(1), resp.ProjectViewID)
assert.InDelta(t, 256.0, resp.Position, 0)
})
t.Run("path task id wins over the body", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// Body names task 2, URL names task 1; the URL must win.
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var resp models.TaskPosition
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
assert.Equal(t, int64(1), resp.TaskID)
})
t.Run("nonexistent task", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "")
require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String())
assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String())
})
t.Run("no access to the task is forbidden", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// testuser15 cannot access task 1 (project 1, owned by testuser1).
token := humaTokenFor(t, &testuser15)
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "")
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
t.Run("read but no write on the task is forbidden", func(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
// Task 32 lives in project 3, on which testuser1 has read-only access.
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "")
require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String())
})
}