From 499941ed394ccefbb64f7bac29f779d708d24cd0 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:36:20 +0100 Subject: [PATCH 01/11] feat(templates): add is_template column migration --- pkg/migration/20260324193413.go | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 pkg/migration/20260324193413.go diff --git a/pkg/migration/20260324193413.go b/pkg/migration/20260324193413.go new file mode 100644 index 000000000..4bcec6f17 --- /dev/null +++ b/pkg/migration/20260324193413.go @@ -0,0 +1,43 @@ +// 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 . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type projects20260324193413 struct { + IsTemplate bool `xorm:"not null default false"` +} + +func (projects20260324193413) TableName() string { + return "projects" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20260324193413", + Description: "Add is_template column to projects", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync(projects20260324193413{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} From 2b4efef119e2c0836e8abdc93b87f7b19da2f9fd Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:38:30 +0100 Subject: [PATCH 02/11] feat(templates): add IsTemplate field to Project model with filtering and validation --- pkg/models/error.go | 54 ++++++++++++++++++++++++++++++++ pkg/models/project.go | 72 +++++++++++++++++++++++++++++++------------ 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/pkg/models/error.go b/pkg/models/error.go index 92c11fd5f..e224f84ed 100644 --- a/pkg/models/error.go +++ b/pkg/models/error.go @@ -528,6 +528,60 @@ func (err *ErrProjectViewDoesNotExist) HTTPError() web.HTTPError { } } +// ErrTemplateCannotHaveParentProject represents an error where a template project is being assigned a parent +type ErrTemplateCannotHaveParentProject struct { + ProjectID int64 +} + +// IsErrTemplateCannotHaveParentProject checks if an error is a ErrTemplateCannotHaveParentProject. +func IsErrTemplateCannotHaveParentProject(err error) bool { + _, ok := err.(*ErrTemplateCannotHaveParentProject) + return ok +} + +func (err *ErrTemplateCannotHaveParentProject) Error() string { + return fmt.Sprintf("Template project cannot have a parent project [ProjectID: %d]", err.ProjectID) +} + +// ErrCodeTemplateCannotHaveParentProject holds the unique world-error code of this error +const ErrCodeTemplateCannotHaveParentProject = 3015 + +// HTTPError holds the http error description +func (err *ErrTemplateCannotHaveParentProject) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeTemplateCannotHaveParentProject, + Message: "Template projects cannot have a parent project.", + } +} + +// ErrCannotMakeDefaultProjectTemplate represents an error where the default project is being made a template +type ErrCannotMakeDefaultProjectTemplate struct { + ProjectID int64 +} + +// IsErrCannotMakeDefaultProjectTemplate checks if an error is a ErrCannotMakeDefaultProjectTemplate. +func IsErrCannotMakeDefaultProjectTemplate(err error) bool { + _, ok := err.(*ErrCannotMakeDefaultProjectTemplate) + return ok +} + +func (err *ErrCannotMakeDefaultProjectTemplate) Error() string { + return fmt.Sprintf("Default project cannot be made a template [ProjectID: %d]", err.ProjectID) +} + +// ErrCodeCannotMakeDefaultProjectTemplate holds the unique world-error code of this error +const ErrCodeCannotMakeDefaultProjectTemplate = 3016 + +// HTTPError holds the http error description +func (err *ErrCannotMakeDefaultProjectTemplate) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusPreconditionFailed, + Code: ErrCodeCannotMakeDefaultProjectTemplate, + Message: "This project cannot be made a template because it is the default project of a user.", + } +} + // ============== // Task errors // ============== diff --git a/pkg/models/project.go b/pkg/models/project.go index c860ed062..a8e3f9a34 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -58,6 +58,9 @@ type Project struct { // Whether a project is archived. IsArchived bool `xorm:"not null default false" json:"is_archived" query:"is_archived"` + // Whether a project is a template. + IsTemplate bool `xorm:"not null default false" json:"is_template" query:"is_template"` + // The id of the file this project has set as background BackgroundFileID int64 `xorm:"null" json:"-"` // Holds extra information about the background set since some background providers require attribution or similar. If not null, the background can be accessed at /projects/{projectID}/background @@ -168,6 +171,7 @@ var FavoritesPseudoProject = Project{ // @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page." // @Param s query string false "Search projects by title." // @Param is_archived query bool false "If true, also returns all archived projects." +// @Param is_template query bool false "If true, returns only template projects. If false (default), excludes templates." // @Param expand query string false "If set to `permissions`, Vikunja will return the max permission the current user has on this project. You can currently only set this to `permissions`." // @Security JWTKeyAuth // @Success 200 {array} models.Project "The projects" @@ -175,7 +179,7 @@ var FavoritesPseudoProject = Project{ // @Failure 500 {object} models.Message "Internal error" // @Router /projects [get] func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int, perPage int) (result interface{}, resultCount int, totalItems int64, err error) { - prs, resultCount, totalItems, err := getAllRawProjects(s, a, search, page, perPage, p.IsArchived, false) + prs, resultCount, totalItems, err := getAllRawProjects(s, a, search, page, perPage, p.IsArchived, p.IsTemplate, false) if err != nil { return nil, 0, 0, err } @@ -216,9 +220,9 @@ func (p *Project) ReadAll(s *xorm.Session, a web.Auth, search string, page int, return prs, resultCount, totalItems, err } -func getAllRawProjects(s *xorm.Session, a web.Auth, search string, page int, perPage int, isArchived, listAll bool) (projects []*Project, resultCount int, totalItems int64, err error) { +func getAllRawProjects(s *xorm.Session, a web.Auth, search string, page int, perPage int, isArchived, isTemplate, listAll bool) (projects []*Project, resultCount int, totalItems int64, err error) { if listAll { - return getRawProjectsUnscoped(s, search, page, perPage, isArchived) + return getRawProjectsUnscoped(s, search, page, perPage, isArchived, isTemplate) } // Check if we're dealing with a share auth @@ -244,11 +248,12 @@ func getAllRawProjects(s *xorm.Session, a web.Auth, search string, page int, per prs, resultCount, totalItems, err := getRawProjectsForUser( s, &projectOptions{ - search: search, - user: doer, - page: page, - perPage: perPage, - getArchived: isArchived, + search: search, + user: doer, + page: page, + perPage: perPage, + getArchived: isArchived, + getTemplates: isTemplate, }) if err != nil { return nil, 0, 0, err @@ -271,7 +276,7 @@ func getAllRawProjects(s *xorm.Session, a web.Auth, search string, page int, per // ListAllProjects returns every project with owners hydrated; callers must authorize since this bypasses the per-user permission filter. func ListAllProjects(s *xorm.Session, search string, page, perPage int, isArchived bool) (projects []*Project, resultCount int, totalItems int64, err error) { - projects, resultCount, totalItems, err = getAllRawProjects(s, nil, search, page, perPage, isArchived, true) + projects, resultCount, totalItems, err = getAllRawProjects(s, nil, search, page, perPage, isArchived, true, true) if err != nil { return nil, 0, 0, err } @@ -293,13 +298,16 @@ func ListAllProjects(s *xorm.Session, search string, page, perPage int, isArchiv return projects, resultCount, totalItems, nil } -func getRawProjectsUnscoped(s *xorm.Session, search string, page, perPage int, isArchived bool) (projects []*Project, resultCount int, totalItems int64, err error) { +func getRawProjectsUnscoped(s *xorm.Session, search string, page, perPage int, isArchived, isTemplate bool) (projects []*Project, resultCount int, totalItems int64, err error) { limit, start := getLimitFromPageIndex(page, perPage) conds := []builder.Cond{} if !isArchived { conds = append(conds, builder.Eq{"is_archived": false}) } + if !isTemplate { + conds = append(conds, builder.Eq{"is_template": false}) + } if search != "" { ids := []int64{} for _, val := range strings.Split(search, ",") { @@ -525,11 +533,12 @@ func GetProjectsByIDs(s *xorm.Session, projectIDs []int64) (projects []*Project, } type projectOptions struct { - search string - user *user.User - page int - perPage int - getArchived bool + search string + user *user.User + page int + perPage int + getArchived bool + getTemplates bool } func getUserProjectsStatement(userID int64, search string) *builder.Builder { @@ -661,6 +670,7 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id` "all_projects.owner_id", "CASE WHEN all_projects.parent_project_id IS NULL THEN 0 ELSE all_projects.parent_project_id END AS parent_project_id", "MAX(CASE WHEN all_projects.is_archived THEN 1 ELSE 0 END) AS is_archived", + "all_projects.is_template", "all_projects.background_file_id", "all_projects.background_blur_hash", "all_projects.position", @@ -678,20 +688,28 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id` "all_projects.parent_project_id", "all_projects.background_file_id", "all_projects.background_blur_hash", + "all_projects.is_template", "all_projects.position", "all_projects.created", "all_projects.updated", }, ", ") - var archivedFilter string + var havingClauses []string if !opts.getArchived { - archivedFilter = "HAVING MAX(CASE WHEN all_projects.is_archived THEN 1 ELSE 0 END) = 0 " + havingClauses = append(havingClauses, "MAX(CASE WHEN all_projects.is_archived THEN 1 ELSE 0 END) = 0") + } + if !opts.getTemplates { + havingClauses = append(havingClauses, "all_projects.is_template = 0") + } + var havingFilter string + if len(havingClauses) > 0 { + havingFilter = "HAVING " + strings.Join(havingClauses, " AND ") + " " } currentProjects := []*Project{} err = s.SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`) SELECT `+columnStr+` FROM all_projects -GROUP BY `+groupByStr+` `+archivedFilter+`ORDER BY all_projects.position `+limitSQL, args...).Find(¤tProjects) +GROUP BY `+groupByStr+` `+havingFilter+`ORDER BY all_projects.position `+limitSQL, args...).Find(¤tProjects) if err != nil { return } @@ -702,7 +720,7 @@ GROUP BY `+groupByStr+` `+archivedFilter+`ORDER BY all_projects.position `+limit totalCount, err = s. SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`) -SELECT COUNT(*) FROM (SELECT all_projects.id FROM all_projects GROUP BY all_projects.id `+archivedFilter+`) sub`, args...). +SELECT COUNT(*) FROM (SELECT all_projects.id FROM all_projects GROUP BY all_projects.id `+havingFilter+`) sub`, args...). Count(&Project{}) if err != nil { return nil, 0, err @@ -956,6 +974,10 @@ func checkProjectBeforeUpdateOrDelete(s *xorm.Session, project *Project) (err er return &ErrProjectCannotBelongToAPseudoParentProject{ProjectID: project.ID, ParentProjectID: project.ParentProjectID} } + if project.IsTemplate && project.ParentProjectID != 0 { + return &ErrTemplateCannotHaveParentProject{ProjectID: project.ID} + } + // Check if the parent project exists if project.ParentProjectID > 0 { if project.ParentProjectID == project.ID { @@ -1148,6 +1170,17 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje } } + if project.IsTemplate { + isDefaultProject, err := project.isDefaultProject(s) + if err != nil { + return err + } + + if isDefaultProject { + return &ErrCannotMakeDefaultProjectTemplate{ProjectID: project.ID} + } + } + err = setArchiveStateForProjectDescendants(s, project.ID, project.IsArchived) if err != nil { return err @@ -1157,6 +1190,7 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje colsToUpdate := []string{ "title", "is_archived", + "is_template", "identifier", "hex_color", "parent_project_id", From ab269afd613b9ba075e9fd47a4a5c17ba755ba1a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:39:59 +0100 Subject: [PATCH 03/11] feat(templates): add save-as-template endpoint --- pkg/models/project_duplicate.go | 161 +++++++++++++++++--------------- pkg/models/project_template.go | 112 ++++++++++++++++++++++ pkg/routes/routes.go | 7 ++ 3 files changed, 204 insertions(+), 76 deletions(-) create mode 100644 pkg/models/project_template.go diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 2ad0857ec..dadfebe29 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -38,6 +38,11 @@ type ProjectDuplicate struct { // The copied project Project *Project `json:"duplicated_project,omitempty"` + // If true, skip copying user/team permissions and link shares + SkipPermissions bool `json:"-"` + // If true, skip copying task assignees and comments + SkipAssigneesAndComments bool `json:"-"` + web.Permissions `json:"-"` web.CRUDable `json:"-"` } @@ -117,56 +122,58 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) { return } - // Permissions / Shares - // To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent - users := []*ProjectUser{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&users) - if err != nil { - return - } - for _, u := range users { - u.ID = 0 - u.ProjectID = pd.Project.ID - if _, err := s.Insert(u); err != nil { - return err - } - } - - log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID) - - teams := []*TeamProject{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&teams) - if err != nil { - return - } - for _, t := range teams { - t.ID = 0 - t.ProjectID = pd.Project.ID - if _, err := s.Insert(t); err != nil { - return err - } - } - - // Generate new link shares if any are available - linkShares := []*LinkSharing{} - err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares) - if err != nil { - return - } - for _, share := range linkShares { - share.ID = 0 - share.ProjectID = pd.Project.ID - hash, err := utils.CryptoRandomString(40) + if !pd.SkipPermissions { + // Permissions / Shares + // To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent + users := []*ProjectUser{} + err = s.Where("project_id = ?", pd.ProjectID).Find(&users) if err != nil { - return err + return } - share.Hash = hash - if _, err := s.Insert(share); err != nil { - return err + for _, u := range users { + u.ID = 0 + u.ProjectID = pd.Project.ID + if _, err := s.Insert(u); err != nil { + return err + } } - } - log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID) + log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID) + + teams := []*TeamProject{} + err = s.Where("project_id = ?", pd.ProjectID).Find(&teams) + if err != nil { + return + } + for _, t := range teams { + t.ID = 0 + t.ProjectID = pd.Project.ID + if _, err := s.Insert(t); err != nil { + return err + } + } + + // Generate new link shares if any are available + linkShares := []*LinkSharing{} + err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares) + if err != nil { + return + } + for _, share := range linkShares { + share.ID = 0 + share.ProjectID = pd.Project.ID + hash, err := utils.CryptoRandomString(40) + if err != nil { + return err + } + share.Hash = hash + if _, err := s.Insert(share); err != nil { + return err + } + } + + log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID) + } err = pd.Project.ReadOne(s, doer) return @@ -431,43 +438,45 @@ func duplicateTasks(s *xorm.Session, doer web.Auth, ld *ProjectDuplicate) (newTa log.Debugf("Duplicated all labels from project %d into %d", ld.ProjectID, ld.Project.ID) - // Assignees - // Only copy those assignees who have access to the task - assignees := []*TaskAssginee{} - err = s.In("task_id", oldTaskIDs).Find(&assignees) - if err != nil { - return - } - for _, a := range assignees { - t := &Task{ - ID: newTaskIDs[a.TaskID], - ProjectID: ld.Project.ID, + if !ld.SkipAssigneesAndComments { + // Assignees + // Only copy those assignees who have access to the task + assignees := []*TaskAssginee{} + err = s.In("task_id", oldTaskIDs).Find(&assignees) + if err != nil { + return } - if err := t.addNewAssigneeByID(s, a.UserID, ld.Project, doer); err != nil { - if IsErrUserDoesNotHaveAccessToProject(err) { - continue + for _, a := range assignees { + t := &Task{ + ID: newTaskIDs[a.TaskID], + ProjectID: ld.Project.ID, + } + if err := t.addNewAssigneeByID(s, a.UserID, ld.Project, doer); err != nil { + if IsErrUserDoesNotHaveAccessToProject(err) { + continue + } + return nil, err } - return nil, err } - } - log.Debugf("Duplicated all assignees from project %d into %d", ld.ProjectID, ld.Project.ID) + log.Debugf("Duplicated all assignees from project %d into %d", ld.ProjectID, ld.Project.ID) - // Comments - comments := []*TaskComment{} - err = s.In("task_id", oldTaskIDs).Find(&comments) - if err != nil { - return - } - for _, c := range comments { - c.ID = 0 - c.TaskID = newTaskIDs[c.TaskID] - if _, err := s.Insert(c); err != nil { - return nil, err + // Comments + comments := []*TaskComment{} + err = s.In("task_id", oldTaskIDs).Find(&comments) + if err != nil { + return + } + for _, c := range comments { + c.ID = 0 + c.TaskID = newTaskIDs[c.TaskID] + if _, err := s.Insert(c); err != nil { + return nil, err + } } - } - log.Debugf("Duplicated all comments from project %d into %d", ld.ProjectID, ld.Project.ID) + log.Debugf("Duplicated all comments from project %d into %d", ld.ProjectID, ld.Project.ID) + } // Relations in that project // Low-Effort: Only copy those relations which are between tasks in the same project diff --git a/pkg/models/project_template.go b/pkg/models/project_template.go new file mode 100644 index 000000000..5af4751e2 --- /dev/null +++ b/pkg/models/project_template.go @@ -0,0 +1,112 @@ +// 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 . + +package models + +import ( + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/web" + + "xorm.io/xorm" +) + +// ProjectTemplate represents the action of promoting a project to a template +type ProjectTemplate struct { + // The project id of the project to save as template + ProjectID int64 `json:"-" param:"projectid"` + + // The resulting template project + Project *Project `json:"project,omitempty"` + + web.Permissions `json:"-"` + web.CRUDable `json:"-"` +} + +// CanCreate checks if a user has the right to create a template from a project +func (pt *ProjectTemplate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bool, err error) { + p := &Project{ID: pt.ProjectID} + canCreate, _, err = p.CanRead(s, a) + return canCreate, err +} + +// Create duplicates a project and marks the copy as a template +// @Summary Save a project as a template +// @Description Creates a template by duplicating the project structure (tasks, views, buckets, backgrounds) without permissions, shares, assignees, or comments. +// @tags project +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param projectid path int true "The project ID to save as template" +// @Success 201 {object} models.ProjectTemplate "The created template" +// @Failure 403 {object} web.HTTPError "The user does not have access to the project" +// @Failure 500 {object} models.Message "Internal error" +// @Router /projects/{projectid}/template [put] +func (pt *ProjectTemplate) Create(s *xorm.Session, doer web.Auth) (err error) { + log.Debugf("Creating template from project %d", pt.ProjectID) + + // Use ProjectDuplicate to copy the project + pd := &ProjectDuplicate{ + ProjectID: pt.ProjectID, + SkipPermissions: true, + SkipAssigneesAndComments: true, + } + + // Read the source project + pd.Project = &Project{ID: pt.ProjectID} + err = pd.Project.ReadOne(s, doer) + if err != nil { + return err + } + + // Reset and mark as template + pd.Project.ID = 0 + pd.Project.Identifier = "" + pd.Project.ParentProjectID = 0 + pd.Project.OwnerID = doer.GetID() + pd.Project.IsTemplate = true + + err = CreateProject(s, pd.Project, doer, false, false) + if err != nil { + if IsErrProjectIdentifierIsNotUnique(err) { + pd.Project.Identifier = "" + err = CreateProject(s, pd.Project, doer, false, false) + } + if err != nil { + return err + } + } + + log.Debugf("Created template project %d from project %d", pd.Project.ID, pt.ProjectID) + + newTaskIDs, err := duplicateTasks(s, doer, pd) + if err != nil { + return + } + + err = duplicateViews(s, pd, doer, newTaskIDs) + if err != nil { + return + } + + err = duplicateProjectBackground(s, pd, doer) + if err != nil { + return + } + + pt.Project = pd.Project + err = pt.Project.ReadOne(s, doer) + return +} diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 3511a9e52..ade857653 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -604,6 +604,13 @@ func registerAPIRoutes(a *echo.Group) { } a.PUT("/projects/:projectid/duplicate", projectDuplicateHandler.CreateWeb) + projectTemplateHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.ProjectTemplate{} + }, + } + a.PUT("/projects/:projectid/template", projectTemplateHandler.CreateWeb) + taskHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.Task{} From d7f196be75209711a1833f29563824a0881c2e40 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:40:38 +0100 Subject: [PATCH 04/11] feat(templates): add template project test fixtures --- pkg/db/fixtures/projects.yml | 22 ++++++++++++++++++++++ pkg/db/fixtures/users_projects.yml | 6 ++++++ 2 files changed, 28 insertions(+) diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index 5ab8d6536..a46f91892 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -393,3 +393,25 @@ position: 4300 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +# Template project owned by user 1 +- + id: 44 + title: Template Project + description: A template project for testing + identifier: tmpl1 + owner_id: 1 + is_template: 1 + position: 44 + updated: 2024-01-01 00:00:00 + created: 2024-01-01 00:00:00 +# Template project owned by user 6, shared with user 1 via users_projects +- + id: 45 + title: Shared Template + description: A template shared with user 1 + identifier: stmpl + owner_id: 6 + is_template: 1 + position: 45 + updated: 2024-01-01 00:00:00 + created: 2024-01-01 00:00:00 diff --git a/pkg/db/fixtures/users_projects.yml b/pkg/db/fixtures/users_projects.yml index 26297deb8..163e50365 100644 --- a/pkg/db/fixtures/users_projects.yml +++ b/pkg/db/fixtures/users_projects.yml @@ -118,3 +118,9 @@ permission: 0 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 21 + user_id: 1 + project_id: 45 + permission: 0 + updated: 2024-01-01 00:00:00 + created: 2024-01-01 00:00:00 From 692a6d623dd5d7e6b002ed88b2df7e3e9a424a3b Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:46:33 +0100 Subject: [PATCH 05/11] test(templates): add web tests for template filtering and save-as-template --- pkg/webtests/project_test.go | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/pkg/webtests/project_test.go b/pkg/webtests/project_test.go index dca9bf81e..88a27ccad 100644 --- a/pkg/webtests/project_test.go +++ b/pkg/webtests/project_test.go @@ -94,6 +94,20 @@ func TestProject(t *testing.T) { } } }) + t.Run("Templates excluded by default", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.NotContains(t, rec.Body.String(), `"is_template":true`) + assert.NotContains(t, rec.Body.String(), `Template Project`) + assert.NotContains(t, rec.Body.String(), `Shared Template`) + }) + t.Run("Templates only", func(t *testing.T) { + rec, err := testHandler.testReadAllWithUser(url.Values{"is_template": []string{"true"}}, nil) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `Template Project`) + assert.Contains(t, rec.Body.String(), `Shared Template`) + assert.NotContains(t, rec.Body.String(), `"title":"Test1"`) + }) }) t.Run("ReadOne", func(t *testing.T) { t.Run("Normal", func(t *testing.T) { @@ -465,5 +479,45 @@ func TestProject(t *testing.T) { assert.NotContains(t, rec.Body.String(), `"tasks":`) }) }) + t.Run("Template cannot have parent project", func(t *testing.T) { + _, err := testHandler.testCreateWithUser(nil, nil, `{"title":"Test Template","is_template":true,"parent_project_id":1}`) + require.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrCodeTemplateCannotHaveParentProject) + }) + t.Run("Template as top-level project", func(t *testing.T) { + rec, err := testHandler.testCreateWithUser(nil, nil, `{"title":"Test Template","is_template":true}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"Test Template"`) + assert.Contains(t, rec.Body.String(), `"is_template":true`) + }) + }) +} + +func TestProjectTemplate(t *testing.T) { + testHandler := webHandlerTest{ + user: &testuser1, + strFunc: func() handler.CObject { + return &models.ProjectTemplate{} + }, + t: t, + } + + t.Run("Save as template", func(t *testing.T) { + rec, err := testHandler.testCreateWithUser(nil, map[string]string{"projectid": "3"}, `{}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"is_template":true`) + }) + + t.Run("No access to source project", func(t *testing.T) { + noAccessHandler := webHandlerTest{ + user: &testuser15, + strFunc: func() handler.CObject { + return &models.ProjectTemplate{} + }, + t: t, + } + _, err := noAccessHandler.testCreateWithUser(nil, map[string]string{"projectid": "1"}, `{}`) + require.Error(t, err) + assert.Contains(t, getHTTPErrorMessage(err), `Forbidden`) }) } From 6b6ca25efa4bf93f61aabf5e888fad9c9ebf6d5b Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:47:28 +0100 Subject: [PATCH 06/11] feat(templates): add isTemplate to frontend project model and store --- frontend/src/modelTypes/IProject.ts | 1 + frontend/src/models/project.ts | 1 + frontend/src/stores/projects.ts | 21 +++++++++++++++++---- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/modelTypes/IProject.ts b/frontend/src/modelTypes/IProject.ts index 555b35a97..c7623d854 100644 --- a/frontend/src/modelTypes/IProject.ts +++ b/frontend/src/modelTypes/IProject.ts @@ -12,6 +12,7 @@ export interface IProject extends IAbstract { owner: IUser tasks: ITask[] isArchived: boolean + isTemplate: boolean hexColor: string identifier: string backgroundInformation: unknown | null // FIXME: improve type diff --git a/frontend/src/models/project.ts b/frontend/src/models/project.ts index 53b75a9b8..7f0b6cad9 100644 --- a/frontend/src/models/project.ts +++ b/frontend/src/models/project.ts @@ -17,6 +17,7 @@ export default class ProjectModel extends AbstractModel implements IPr owner: IUser = UserModel tasks: ITask[] = [] isArchived = false + isTemplate = false hexColor = '' identifier = '' backgroundInformation: unknown | null = null diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 37110100e..878a19549 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -37,14 +37,17 @@ export const useProjectStore = defineStore('project', () => { } const notArchivedRootProjects = computed(() => projectsArray.value - .filter(p => !p.isArchived && p.id > 0 && ( + .filter(p => !p.isArchived && !p.isTemplate && p.id > 0 && ( p.parentProjectId === 0 || isOrphanedSubProject(p) ))) const favoriteProjects = computed(() => projectsArray.value - .filter(p => !p.isArchived && p.isFavorite)) + .filter(p => !p.isArchived && !p.isTemplate && p.isFavorite)) const savedFilterProjects = computed(() => projectsArray.value .filter(p => !p.isArchived && p.id < -1)) const hasProjects = computed(() => projectsArray.value.length > 0) + const templateProjects = computed(() => projectsArray.value + .filter(p => !p.isArchived && p.isTemplate)) + const hasTemplates = computed(() => templateProjects.value.length > 0) const getChildProjects = computed(() => { return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id) @@ -265,11 +268,19 @@ export const useProjectStore = defineStore('project', () => { loadedProjects.push(...newProjects) page++ } while (page <= projectService.totalPages) - + + // Fetch templates separately + let templatePage = 1 + do { + const newTemplates = await projectService.getAll({}, {is_template: true, expand: 'permissions'}, templatePage) as IProject[] + loadedProjects.push(...newTemplates) + templatePage++ + } while (templatePage <= projectService.totalPages) + } finally { cancel() } - + projects.value = {} setProjects(loadedProjects) @@ -329,6 +340,8 @@ export const useProjectStore = defineStore('project', () => { favoriteProjects: readonly(favoriteProjects), hasProjects: readonly(hasProjects), savedFilterProjects: readonly(savedFilterProjects), + templateProjects: readonly(templateProjects), + hasTemplates: readonly(hasTemplates), getChildProjects, isOrphanedSubProject, From 3de5206dd4d80521a35fb451eab66ce99ebd6bc6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:49:41 +0100 Subject: [PATCH 07/11] feat(templates): add templates sidebar, template picker, save-as-template action, and i18n keys --- frontend/src/components/home/Navigation.vue | 15 ++++++ .../project/ProjectSettingsDropdown.vue | 23 ++++++++++ frontend/src/i18n/lang/en.json | 8 ++++ frontend/src/views/project/NewProject.vue | 46 +++++++++++++++++-- 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/home/Navigation.vue b/frontend/src/components/home/Navigation.vue index b287362a2..649e25007 100644 --- a/frontend/src/components/home/Navigation.vue +++ b/frontend/src/components/home/Navigation.vue @@ -108,6 +108,20 @@ :can-collapse="true" /> + + projectStore.notArchivedRootProjects as IProject[]) const favoriteProjects = computed(() => projectStore.favoriteProjects as IProject[]) const savedFilterProjects = computed(() => projectStore.savedFilterProjects as IProject[]) +const templateProjects = computed(() => projectStore.templateProjects as IProject[]) diff --git a/pkg/models/project.go b/pkg/models/project.go index a8e3f9a34..463381142 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -593,7 +593,7 @@ func getUserProjectsStatement(userID int64, search string) *builder.Builder { } return builder. - Select("l.id, l.title, l.description, l.identifier, l.hex_color, l.owner_id, l.parent_project_id, l.is_archived, l.background_file_id, l.background_blur_hash, l.position, l.created, l.updated"). + Select("l.id, l.title, l.description, l.identifier, l.hex_color, l.owner_id, l.parent_project_id, l.is_archived, l.is_template, l.background_file_id, l.background_blur_hash, l.position, l.created, l.updated"). From("projects", "l"). Join("LEFT", "team_projects tl", "tl.project_id = l.id"). Join("LEFT", "team_members tm2", "tm2.team_id = tl.team_id"). @@ -658,7 +658,7 @@ func getAllProjectsForUser(s *xorm.Session, userID int64, opts *projectOptions) baseQuery := querySQLString + ` UNION ALL -SELECT p.id, p.title, p.description, p.identifier, p.hex_color, p.owner_id, p.parent_project_id, (ap.is_archived OR p.is_archived) AS is_archived, p.background_file_id, p.background_blur_hash, p.position, p.created, p.updated FROM projects p +SELECT p.id, p.title, p.description, p.identifier, p.hex_color, p.owner_id, p.parent_project_id, (ap.is_archived OR p.is_archived) AS is_archived, p.is_template, p.background_file_id, p.background_blur_hash, p.position, p.created, p.updated FROM projects p INNER JOIN all_projects ap ON p.parent_project_id = ap.id` columnStr := strings.Join([]string{ From 3061b8117a08d611b87d00fb7b1b05e6c6b1d46e Mon Sep 17 00:00:00 2001 From: kolaente Date: Mon, 30 Mar 2026 17:38:22 +0200 Subject: [PATCH 10/11] fix(templates): use aggregate for is_template HAVING clause for MySQL/PostgreSQL compatibility --- pkg/models/project.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pkg/models/project.go b/pkg/models/project.go index 463381142..9eee78c44 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -670,7 +670,7 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id` "all_projects.owner_id", "CASE WHEN all_projects.parent_project_id IS NULL THEN 0 ELSE all_projects.parent_project_id END AS parent_project_id", "MAX(CASE WHEN all_projects.is_archived THEN 1 ELSE 0 END) AS is_archived", - "all_projects.is_template", + "MAX(CASE WHEN all_projects.is_template THEN 1 ELSE 0 END) AS is_template", "all_projects.background_file_id", "all_projects.background_blur_hash", "all_projects.position", @@ -688,7 +688,6 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id` "all_projects.parent_project_id", "all_projects.background_file_id", "all_projects.background_blur_hash", - "all_projects.is_template", "all_projects.position", "all_projects.created", "all_projects.updated", @@ -699,7 +698,7 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id` havingClauses = append(havingClauses, "MAX(CASE WHEN all_projects.is_archived THEN 1 ELSE 0 END) = 0") } if !opts.getTemplates { - havingClauses = append(havingClauses, "all_projects.is_template = 0") + havingClauses = append(havingClauses, "MAX(CASE WHEN all_projects.is_template THEN 1 ELSE 0 END) = 0") } var havingFilter string if len(havingClauses) > 0 { From 0c8cc8e32d920e78c72d64bbbdfdb745b1808dea Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 7 Apr 2026 17:39:33 +0200 Subject: [PATCH 11/11] fix(templates): correct swagger annotation for is_template query param The description said "returns only template projects" but the actual behavior matches is_archived: it includes templates alongside normal projects when set to true. --- pkg/models/project.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/project.go b/pkg/models/project.go index 9eee78c44..1e52f447e 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -171,7 +171,7 @@ var FavoritesPseudoProject = Project{ // @Param per_page query int false "The maximum number of items per page. Note this parameter is limited by the configured maximum of items per page." // @Param s query string false "Search projects by title." // @Param is_archived query bool false "If true, also returns all archived projects." -// @Param is_template query bool false "If true, returns only template projects. If false (default), excludes templates." +// @Param is_template query bool false "If true, also returns all template projects." // @Param expand query string false "If set to `permissions`, Vikunja will return the max permission the current user has on this project. You can currently only set this to `permissions`." // @Security JWTKeyAuth // @Success 200 {array} models.Project "The projects"