feat(api/v2): add project view routes

Add ProjectView CRUD on /api/v2 under the nested path
/projects/{project}/views[/{view}], establishing the two-path-param
binding pattern for sub-resources. Mirrors the labels.go handler shape
and reuses handler.Do* so permission checks stay at the model layer.

Both {project} and {view} are bound on every operation; {project} is
threaded onto ProjectView.ProjectID (ReadOne resolves via
GetProjectViewByIDAndProject, which needs the parent id). List wraps the
[]*models.ProjectView slice in the shared Paginated envelope, read sends
an ETag for If-None-Match/304, and AutoPatch synthesises PATCH.

Also:
- Tag exposed ProjectView / ProjectViewBucketConfiguration / nested
  TaskCollection fields with doc: descriptions; mark server-controlled
  fields (id, project_id, created, updated) readOnly. Safe for v1.
- Give ProjectViewKind and BucketConfigurationModeKind a huma.SchemaProvider
  so the string-serialised enums reflect as string schemas instead of
  Huma's default integer schema (which rejected the string form with 422).

Routes registered in registerAPIRoutesV2 before EnableAutoPatch.
This commit is contained in:
kolaente 2026-05-31 17:54:49 +02:00 committed by kolaente
parent c7e7f8dca3
commit 5ddc9d8ff0
5 changed files with 422 additions and 19 deletions

View File

@ -23,6 +23,7 @@ import (
"code.vikunja.io/api/pkg/web"
"github.com/danielgtaylor/huma/v2"
"xorm.io/xorm"
)
@ -66,6 +67,17 @@ func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error {
return nil
}
// Schema lets Huma (/api/v2) reflect this type as a string enum. The custom
// Marshal/UnmarshalJSON above serialize it as a string, but the underlying Go
// type is an int — without this, Huma would generate an integer schema and
// reject the string form clients actually send.
func (*ProjectViewKind) Schema(_ huma.Registry) *huma.Schema {
return &huma.Schema{
Type: "string",
Enum: []any{"list", "gantt", "table", "kanban"},
}
}
// NOTE: When adding or changing enum values for ProjectViewKind,
// make sure to update the corresponding `enums` tag in the ProjectView struct
// to keep the OpenAPI documentation in sync.
@ -123,39 +135,48 @@ func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error {
return nil
}
// Schema lets Huma (/api/v2) reflect this type as a string enum; see the note
// on ProjectViewKind.Schema for why this is needed.
func (*BucketConfigurationModeKind) Schema(_ huma.Registry) *huma.Schema {
return &huma.Schema{
Type: "string",
Enum: []any{"none", "manual", "filter"},
}
}
type ProjectViewBucketConfiguration struct {
Title string `json:"title"`
Filter *TaskCollection `json:"filter"`
Title string `json:"title" doc:"The title of the bucket this configuration creates."`
Filter *TaskCollection `json:"filter" doc:"The filter query that decides which tasks land in this bucket. See https://vikunja.io/docs/filters."`
}
type ProjectView struct {
// The unique numeric id of this view
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view" readOnly:"true" doc:"The unique, numeric id of this view. Set by the server."`
// The title of this view
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)"`
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of this view."`
// The project this view belongs to
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"`
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project" readOnly:"true" doc:"The project this view belongs to. Taken from the URL path; ignored on write."`
// The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban"`
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban" doc:"The kind of this view. One of list, gantt, table or kanban."`
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter"`
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter" doc:"The filter query used to match tasks shown in this view. See https://vikunja.io/docs/filters."`
// The position of this view in the list. The list of all views will be sorted by this parameter.
Position float64 `xorm:"double null" json:"position"`
Position float64 `xorm:"double null" json:"position" doc:"The position of this view in the project's list of views. Views are sorted ascending by this value."`
// The bucket configuration mode. Can be `none`, `manual` or `filter`. `manual` allows to move tasks between buckets as you normally would. `filter` creates buckets based on a filter for each bucket.
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual"`
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual" doc:"The bucket configuration mode. One of none, manual or filter. manual lets you move tasks between buckets; filter creates a bucket per filter."`
// When the bucket configuration mode is not `manual`, this field holds the options of that configuration.
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"`
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration" doc:"When the bucket configuration mode is filter, holds the title and filter of each bucket."`
// The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"`
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id" doc:"The id of the bucket new tasks without a bucket are added to. Defaults to the leftmost bucket."`
// If tasks are moved to the done bucket, they are marked as done. If they are marked as done individually, they are moved into the done bucket.
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"`
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id" doc:"The id of the done bucket. Tasks moved here are marked done, and tasks marked done are moved here."`
// A timestamp when this view was updated. You cannot change this value.
Updated time.Time `xorm:"updated not null" json:"updated"`
Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"A timestamp when this view was last updated. You cannot change this value."`
// A timestamp when this reaction was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this view was created. You cannot change this value."`
web.CRUDable `xorm:"-" json:"-"`
web.Permissions `xorm:"-" json:"-"`

View File

@ -32,20 +32,20 @@ type TaskCollection struct {
ProjectID int64 `param:"project" json:"-"`
ProjectViewID int64 `param:"view" json:"-"`
Search string `query:"s" json:"s"`
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
// The query parameter to sort by. This is for ex. done, priority, etc.
SortBy []string `query:"sort_by" json:"sort_by"`
SortBy []string `query:"sort_by" json:"sort_by" doc:"The fields to sort by, for example done or priority."`
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
OrderBy []string `query:"order_by" json:"order_by"`
OrderBy []string `query:"order_by" json:"order_by" doc:"The order for each sort_by field, either asc or desc. Defaults to asc."`
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
Filter string `query:"filter" json:"filter"`
Filter string `query:"filter" json:"filter" doc:"The filter query to match tasks by. See https://vikunja.io/docs/filters."`
// The time zone which should be used for date match (statements like "now" resolve to different actual times)
FilterTimezone string `query:"filter_timezone" json:"-"`
// If set to true, the result will also include null values
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls" doc:"If true, the result also includes tasks whose filtered field is null."`
// If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a
// second step, will fetch all of these subtasks. This may result in more tasks than the

View File

@ -0,0 +1,177 @@
// 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"
"fmt"
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/web/handler"
"github.com/danielgtaylor/huma/v2"
"github.com/danielgtaylor/huma/v2/conditional"
)
// projectViewListBody is the list-response envelope. models.ProjectView.ReadAll
// returns []*models.ProjectView, so that's the element type.
type projectViewListBody struct {
Body Paginated[*models.ProjectView]
}
// RegisterProjectViewRoutes wires the nested ProjectView CRUD onto the Huma API.
// Every operation binds two path params: {project} → ProjectID and {view} → ID.
// This is the reference shape every nested sub-resource copies.
func RegisterProjectViewRoutes(api huma.API) {
tags := []string{"project_views"}
Register(api, huma.Operation{
OperationID: "project-views-list",
Summary: "List the views of a project",
Description: "Returns all views of the given project. Requires read access to the project; the list is not paginated by the server but is returned in the standard list envelope.",
Method: http.MethodGet,
Path: "/projects/{project}/views",
Tags: tags,
}, projectViewsList)
Register(api, huma.Operation{
OperationID: "project-views-read",
Summary: "Get a single view of a project",
Description: "Returns one view of a project. The view must belong to the project in the path. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified.",
Method: http.MethodGet,
Path: "/projects/{project}/views/{view}",
Tags: tags,
}, projectViewsRead)
Register(api, huma.Operation{
OperationID: "project-views-create",
Summary: "Create a view in a project",
Description: "Creates a view in the given project. The parent project is taken from the URL, not the body. Only project admins may create a view.",
Method: http.MethodPost,
Path: "/projects/{project}/views",
Tags: tags,
}, projectViewsCreate)
Register(api, huma.Operation{
OperationID: "project-views-update",
Summary: "Update a view of a project",
Description: "Replaces a project view's fields. The view must belong to the project in the path, and only project admins may update it. Use PATCH for a partial update.",
Method: http.MethodPut,
Path: "/projects/{project}/views/{view}",
Tags: tags,
}, projectViewsUpdate)
Register(api, huma.Operation{
OperationID: "project-views-delete",
Summary: "Delete a view of a project",
Description: "Deletes a project view along with its buckets and task positions. Only project admins may delete it.",
Method: http.MethodDelete,
Path: "/projects/{project}/views/{view}",
Tags: tags,
}, projectViewsDelete)
}
func projectViewsList(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ListParams
}) (*projectViewListBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
result, _, total, err := handler.DoReadAll(ctx, &models.ProjectView{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage)
if err != nil {
return nil, translateDomainError(err)
}
items, ok := result.([]*models.ProjectView)
if !ok {
return nil, fmt.Errorf("projectViews.ReadAll returned unexpected type %T (expected []*models.ProjectView)", result)
}
return &projectViewListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
}
func projectViewsRead(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ID int64 `path:"view"`
conditional.Params
}) (*singleReadBody[models.ProjectView], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
// ReadOne resolves the view via GetProjectViewByIDAndProject, which needs
// both ids — the parent project scopes the lookup.
view := &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}
if _, err := handler.DoReadOne(ctx, view, a); err != nil {
return nil, translateDomainError(err)
}
// PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
etag := fmt.Sprintf("%d-%d", view.ID, view.Updated.UnixNano())
if in.HasConditionalParams() {
if err := in.PreconditionFailed(etag, view.Updated); err != nil {
return nil, err
}
}
return &singleReadBody[models.ProjectView]{ETag: `"` + etag + `"`, Body: view}, nil
}
func projectViewsCreate(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
Body models.ProjectView
}) (*singleBody[models.ProjectView], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
in.Body.ProjectID = in.ProjectID // URL wins over body
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.ProjectView]{Body: &in.Body}, nil
}
func projectViewsUpdate(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ID int64 `path:"view"`
Body models.ProjectView
}) (*singleBody[models.ProjectView], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
in.Body.ID = in.ID // URL wins over body
in.Body.ProjectID = in.ProjectID // parent from the path scopes the update
if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.ProjectView]{Body: &in.Body}, nil
}
func projectViewsDelete(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ID int64 `path:"view"`
}) (*emptyBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
if err := handler.DoDelete(ctx, &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}, a); err != nil {
return nil, translateDomainError(err)
}
return &emptyBody{}, nil
}

View File

@ -396,6 +396,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
// Resource registrations.
apiv2.RegisterLabelRoutes(api)
apiv2.RegisterTaskDuplicateRoutes(api)
apiv2.RegisterProjectViewRoutes(api)
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
// synthesize their PATCH counterparts.

View File

@ -0,0 +1,204 @@
// 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"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestProjectView is the nested-path reference test for /api/v2. Views live
// under /projects/{project}/views/{view}, so the harness binds two path params:
// basePath carries the literal {project} and idParam picks {view}.
//
// Fixtures: project 1 (owned by testuser1) has views 1-4; project 2 (owned by
// user3, no share to testuser1) has views 5-8 — used for the forbidden and
// wrong-parent negatives.
func TestProjectView(t *testing.T) {
// project 1 is owned by testuser1.
owned := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/1/views",
idParam: "view",
t: t,
}
require.NoError(t, owned.ensureEnv())
// project 2 is owned by user3; testuser1 has no access. Share owned's Echo
// instance: each setupTestEnv() regenerates the global JWT signing secret,
// so two independent harnesses would invalidate each other's tokens.
forbidden := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/2/views",
idParam: "view",
t: t,
e: owned.e,
}
t.Run("ReadAll", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testReadAllWithUser(nil, nil)
require.NoError(t, err)
// project 1's four default views, none from project 2.
assert.Contains(t, rec.Body.String(), `"title":"List"`)
assert.Contains(t, rec.Body.String(), `"title":"Gantt"`)
assert.Contains(t, rec.Body.String(), `"title":"Table"`)
assert.Contains(t, rec.Body.String(), `"title":"Kanban"`)
assert.Contains(t, rec.Body.String(), `"project_id":1`)
assert.NotContains(t, rec.Body.String(), `"project_id":2`)
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testReadAllWithUser(nil, nil)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("ReadOne", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testReadOneWithUser(nil, map[string]string{"view": "1"})
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"List"`)
assert.Contains(t, rec.Body.String(), `"id":1`)
assert.NotEmpty(t, rec.Result().Header.Get("ETag"))
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := owned.testReadOneWithUser(nil, map[string]string{"view": "9999"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("View from another project", func(t *testing.T) {
// view 5 belongs to project 2; reading it under project 1 must 404.
_, err := owned.testReadOneWithUser(nil, map[string]string{"view": "5"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Forbidden", func(t *testing.T) {
// view 5 read under its real parent (project 2) — no access.
_, err := forbidden.testReadOneWithUser(nil, map[string]string{"view": "5"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testCreateWithUser(nil, nil, `{"title":"New view","view_kind":"list"}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"New view"`)
// ownership: the parent project from the URL wins.
assert.Contains(t, rec.Body.String(), `"project_id":1`)
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testCreateWithUser(nil, nil, `{"title":"Nope","view_kind":"list"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Update", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testUpdateWithUser(nil, map[string]string{"view": "1"}, `{"title":"Renamed list","view_kind":"list"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Renamed list"`)
assert.Contains(t, rec.Body.String(), `"id":1`)
})
t.Run("View from another project", func(t *testing.T) {
_, err := owned.testUpdateWithUser(nil, map[string]string{"view": "5"}, `{"title":"x","view_kind":"list"}`)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testUpdateWithUser(nil, map[string]string{"view": "5"}, `{"title":"x","view_kind":"list"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Delete", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testDeleteWithUser(nil, map[string]string{"view": "2"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testDeleteWithUser(nil, map[string]string{"view": "5"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
}
// TestProjectView_ETagReturns304 covers v2-only conditional-request behaviour.
func TestProjectView_ETagReturns304(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
rec := humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/views/1", "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
etag := rec.Header().Get("ETag")
require.NotEmpty(t, etag, "GET must return an ETag header")
req := httptest.NewRequest(http.MethodGet, "/api/v2/projects/1/views/1", strings.NewReader(""))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("If-None-Match", etag)
rec = httptest.NewRecorder()
e.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String())
}
// TestProjectView_PATCHMergePatch confirms AutoPatch synthesises a PATCH that
// only touches supplied fields.
func TestProjectView_PATCHMergePatch(t *testing.T) {
e, err := setupTestEnv()
require.NoError(t, err)
token := humaTokenFor(t, &testuser1)
// Create a fresh view so we don't stomp fixtures.
rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/views",
`{"title":"before","view_kind":"table"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
var created struct {
ID int64 `json:"id"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created))
// PATCH only title; view_kind must survive.
rec = humaRequest(t, e, http.MethodPatch, fmt.Sprintf("/api/v2/projects/1/views/%d", created.ID),
`{"title":"after"}`, token, "application/merge-patch+json")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/projects/1/views/%d", created.ID), "", token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
var after struct {
Title string `json:"title"`
ViewKind string `json:"view_kind"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &after))
assert.Equal(t, "after", after.Title)
assert.Equal(t, "table", after.ViewKind, "view_kind must survive the PATCH")
}