Skip to content

Commit f5df49e

Browse files
committed
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.
1 parent 5bbc5aa commit f5df49e

5 files changed

Lines changed: 422 additions & 19 deletions

File tree

pkg/models/project_view.go

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323

2424
"code.vikunja.io/api/pkg/web"
2525

26+
"github.com/danielgtaylor/huma/v2"
2627
"xorm.io/xorm"
2728
)
2829

@@ -66,6 +67,17 @@ func (p *ProjectViewKind) UnmarshalJSON(bytes []byte) error {
6667
return nil
6768
}
6869

70+
// Schema lets Huma (/api/v2) reflect this type as a string enum. The custom
71+
// Marshal/UnmarshalJSON above serialize it as a string, but the underlying Go
72+
// type is an int — without this, Huma would generate an integer schema and
73+
// reject the string form clients actually send.
74+
func (*ProjectViewKind) Schema(_ huma.Registry) *huma.Schema {
75+
return &huma.Schema{
76+
Type: "string",
77+
Enum: []any{"list", "gantt", "table", "kanban"},
78+
}
79+
}
80+
6981
// NOTE: When adding or changing enum values for ProjectViewKind,
7082
// make sure to update the corresponding `enums` tag in the ProjectView struct
7183
// to keep the OpenAPI documentation in sync.
@@ -123,39 +135,48 @@ func (p *BucketConfigurationModeKind) UnmarshalJSON(bytes []byte) error {
123135
return nil
124136
}
125137

138+
// Schema lets Huma (/api/v2) reflect this type as a string enum; see the note
139+
// on ProjectViewKind.Schema for why this is needed.
140+
func (*BucketConfigurationModeKind) Schema(_ huma.Registry) *huma.Schema {
141+
return &huma.Schema{
142+
Type: "string",
143+
Enum: []any{"none", "manual", "filter"},
144+
}
145+
}
146+
126147
type ProjectViewBucketConfiguration struct {
127-
Title string `json:"title"`
128-
Filter *TaskCollection `json:"filter"`
148+
Title string `json:"title" doc:"The title of the bucket this configuration creates."`
149+
Filter *TaskCollection `json:"filter" doc:"The filter query that decides which tasks land in this bucket. See https://vikunja.io/docs/filters."`
129150
}
130151

131152
type ProjectView struct {
132153
// The unique numeric id of this view
133-
ID int64 `xorm:"autoincr not null unique pk" json:"id" param:"view"`
154+
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."`
134155
// The title of this view
135-
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)"`
156+
Title string `xorm:"varchar(255) not null" json:"title" valid:"required,runelength(1|250)" minLength:"1" maxLength:"250" doc:"The title of this view."`
136157
// The project this view belongs to
137-
ProjectID int64 `xorm:"not null index" json:"project_id" param:"project"`
158+
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."`
138159
// The kind of this view. Can be `list`, `gantt`, `table` or `kanban`.
139-
ViewKind ProjectViewKind `xorm:"not null" json:"view_kind" swaggertype:"string" enums:"list,gantt,table,kanban"`
160+
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."`
140161

141162
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
142-
Filter *TaskCollection `xorm:"json null default null" query:"filter" json:"filter"`
163+
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."`
143164
// The position of this view in the list. The list of all views will be sorted by this parameter.
144-
Position float64 `xorm:"double null" json:"position"`
165+
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."`
145166

146167
// 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.
147-
BucketConfigurationMode BucketConfigurationModeKind `xorm:"default 0" json:"bucket_configuration_mode" swaggertype:"string" enums:"none,manual,filter,manual"`
168+
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."`
148169
// When the bucket configuration mode is not `manual`, this field holds the options of that configuration.
149-
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration"`
170+
BucketConfiguration []*ProjectViewBucketConfiguration `xorm:"json" json:"bucket_configuration" doc:"When the bucket configuration mode is filter, holds the title and filter of each bucket."`
150171
// The ID of the bucket where new tasks without a bucket are added to. By default, this is the leftmost bucket in a view.
151-
DefaultBucketID int64 `xorm:"bigint INDEX null" json:"default_bucket_id"`
172+
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."`
152173
// 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.
153-
DoneBucketID int64 `xorm:"bigint INDEX null" json:"done_bucket_id"`
174+
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."`
154175

155176
// A timestamp when this view was updated. You cannot change this value.
156-
Updated time.Time `xorm:"updated not null" json:"updated"`
177+
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."`
157178
// A timestamp when this reaction was created. You cannot change this value.
158-
Created time.Time `xorm:"created not null" json:"created"`
179+
Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this view was created. You cannot change this value."`
159180

160181
web.CRUDable `xorm:"-" json:"-"`
161182
web.Permissions `xorm:"-" json:"-"`

pkg/models/task_collection.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,20 @@ type TaskCollection struct {
3232
ProjectID int64 `param:"project" json:"-"`
3333
ProjectViewID int64 `param:"view" json:"-"`
3434

35-
Search string `query:"s" json:"s"`
35+
Search string `query:"s" json:"s" doc:"A search term to match tasks by their title."`
3636

3737
// The query parameter to sort by. This is for ex. done, priority, etc.
38-
SortBy []string `query:"sort_by" json:"sort_by"`
38+
SortBy []string `query:"sort_by" json:"sort_by" doc:"The fields to sort by, for example done or priority."`
3939
// The query parameter to order the items by. This can be either asc or desc, with asc being the default.
40-
OrderBy []string `query:"order_by" json:"order_by"`
40+
OrderBy []string `query:"order_by" json:"order_by" doc:"The order for each sort_by field, either asc or desc. Defaults to asc."`
4141

4242
// The filter query to match tasks by. Check out https://vikunja.io/docs/filters for a full explanation.
43-
Filter string `query:"filter" json:"filter"`
43+
Filter string `query:"filter" json:"filter" doc:"The filter query to match tasks by. See https://vikunja.io/docs/filters."`
4444
// The time zone which should be used for date match (statements like "now" resolve to different actual times)
4545
FilterTimezone string `query:"filter_timezone" json:"-"`
4646

4747
// If set to true, the result will also include null values
48-
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls"`
48+
FilterIncludeNulls bool `query:"filter_include_nulls" json:"filter_include_nulls" doc:"If true, the result also includes tasks whose filtered field is null."`
4949

5050
// If set to `subtasks`, Vikunja will fetch only tasks which do not have subtasks and then in a
5151
// second step, will fetch all of these subtasks. This may result in more tasks than the

pkg/routes/api/v2/project_views.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
// Vikunja is a to-do list application to facilitate your life.
2+
// Copyright 2018-present Vikunja and contributors. All rights reserved.
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as published by
6+
// the Free Software Foundation, either version 3 of the License, or
7+
// (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
17+
package apiv2
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"net/http"
23+
24+
"code.vikunja.io/api/pkg/models"
25+
"code.vikunja.io/api/pkg/web/handler"
26+
27+
"github.com/danielgtaylor/huma/v2"
28+
"github.com/danielgtaylor/huma/v2/conditional"
29+
)
30+
31+
// projectViewListBody is the list-response envelope. models.ProjectView.ReadAll
32+
// returns []*models.ProjectView, so that's the element type.
33+
type projectViewListBody struct {
34+
Body Paginated[*models.ProjectView]
35+
}
36+
37+
// RegisterProjectViewRoutes wires the nested ProjectView CRUD onto the Huma API.
38+
// Every operation binds two path params: {project} → ProjectID and {view} → ID.
39+
// This is the reference shape every nested sub-resource copies.
40+
func RegisterProjectViewRoutes(api huma.API) {
41+
tags := []string{"project_views"}
42+
43+
Register(api, huma.Operation{
44+
OperationID: "project-views-list",
45+
Summary: "List the views of a project",
46+
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.",
47+
Method: http.MethodGet,
48+
Path: "/projects/{project}/views",
49+
Tags: tags,
50+
}, projectViewsList)
51+
52+
Register(api, huma.Operation{
53+
OperationID: "project-views-read",
54+
Summary: "Get a single view of a project",
55+
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.",
56+
Method: http.MethodGet,
57+
Path: "/projects/{project}/views/{view}",
58+
Tags: tags,
59+
}, projectViewsRead)
60+
61+
Register(api, huma.Operation{
62+
OperationID: "project-views-create",
63+
Summary: "Create a view in a project",
64+
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.",
65+
Method: http.MethodPost,
66+
Path: "/projects/{project}/views",
67+
Tags: tags,
68+
}, projectViewsCreate)
69+
70+
Register(api, huma.Operation{
71+
OperationID: "project-views-update",
72+
Summary: "Update a view of a project",
73+
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.",
74+
Method: http.MethodPut,
75+
Path: "/projects/{project}/views/{view}",
76+
Tags: tags,
77+
}, projectViewsUpdate)
78+
79+
Register(api, huma.Operation{
80+
OperationID: "project-views-delete",
81+
Summary: "Delete a view of a project",
82+
Description: "Deletes a project view along with its buckets and task positions. Only project admins may delete it.",
83+
Method: http.MethodDelete,
84+
Path: "/projects/{project}/views/{view}",
85+
Tags: tags,
86+
}, projectViewsDelete)
87+
}
88+
89+
func projectViewsList(ctx context.Context, in *struct {
90+
ProjectID int64 `path:"project"`
91+
ListParams
92+
}) (*projectViewListBody, error) {
93+
a, err := authFromCtx(ctx)
94+
if err != nil {
95+
return nil, err
96+
}
97+
result, _, total, err := handler.DoReadAll(ctx, &models.ProjectView{ProjectID: in.ProjectID}, a, in.Q, in.Page, in.PerPage)
98+
if err != nil {
99+
return nil, translateDomainError(err)
100+
}
101+
items, ok := result.([]*models.ProjectView)
102+
if !ok {
103+
return nil, fmt.Errorf("projectViews.ReadAll returned unexpected type %T (expected []*models.ProjectView)", result)
104+
}
105+
return &projectViewListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil
106+
}
107+
108+
func projectViewsRead(ctx context.Context, in *struct {
109+
ProjectID int64 `path:"project"`
110+
ID int64 `path:"view"`
111+
conditional.Params
112+
}) (*singleReadBody[models.ProjectView], error) {
113+
a, err := authFromCtx(ctx)
114+
if err != nil {
115+
return nil, err
116+
}
117+
// ReadOne resolves the view via GetProjectViewByIDAndProject, which needs
118+
// both ids — the parent project scopes the lookup.
119+
view := &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}
120+
if _, err := handler.DoReadOne(ctx, view, a); err != nil {
121+
return nil, translateDomainError(err)
122+
}
123+
// PreconditionFailed wants the unquoted etag; response header uses RFC 9110 quoted form.
124+
etag := fmt.Sprintf("%d-%d", view.ID, view.Updated.UnixNano())
125+
if in.HasConditionalParams() {
126+
if err := in.PreconditionFailed(etag, view.Updated); err != nil {
127+
return nil, err
128+
}
129+
}
130+
return &singleReadBody[models.ProjectView]{ETag: `"` + etag + `"`, Body: view}, nil
131+
}
132+
133+
func projectViewsCreate(ctx context.Context, in *struct {
134+
ProjectID int64 `path:"project"`
135+
Body models.ProjectView
136+
}) (*singleBody[models.ProjectView], error) {
137+
a, err := authFromCtx(ctx)
138+
if err != nil {
139+
return nil, err
140+
}
141+
in.Body.ProjectID = in.ProjectID // URL wins over body
142+
if err := handler.DoCreate(ctx, &in.Body, a); err != nil {
143+
return nil, translateDomainError(err)
144+
}
145+
return &singleBody[models.ProjectView]{Body: &in.Body}, nil
146+
}
147+
148+
func projectViewsUpdate(ctx context.Context, in *struct {
149+
ProjectID int64 `path:"project"`
150+
ID int64 `path:"view"`
151+
Body models.ProjectView
152+
}) (*singleBody[models.ProjectView], error) {
153+
a, err := authFromCtx(ctx)
154+
if err != nil {
155+
return nil, err
156+
}
157+
in.Body.ID = in.ID // URL wins over body
158+
in.Body.ProjectID = in.ProjectID // parent from the path scopes the update
159+
if err := handler.DoUpdate(ctx, &in.Body, a); err != nil {
160+
return nil, translateDomainError(err)
161+
}
162+
return &singleBody[models.ProjectView]{Body: &in.Body}, nil
163+
}
164+
165+
func projectViewsDelete(ctx context.Context, in *struct {
166+
ProjectID int64 `path:"project"`
167+
ID int64 `path:"view"`
168+
}) (*emptyBody, error) {
169+
a, err := authFromCtx(ctx)
170+
if err != nil {
171+
return nil, err
172+
}
173+
if err := handler.DoDelete(ctx, &models.ProjectView{ID: in.ID, ProjectID: in.ProjectID}, a); err != nil {
174+
return nil, translateDomainError(err)
175+
}
176+
return &emptyBody{}, nil
177+
}

pkg/routes/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ func registerAPIRoutesV2(e *echo.Echo, a *echo.Group) {
395395

396396
// Resource registrations.
397397
apiv2.RegisterLabelRoutes(api)
398+
apiv2.RegisterProjectViewRoutes(api)
398399

399400
// AutoPatch must run AFTER all GET/PUT pairs are registered so it can
400401
// synthesize their PATCH counterparts.

0 commit comments

Comments
 (0)