From ab269afd613b9ba075e9fd47a4a5c17ba755ba1a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 24 Mar 2026 19:39:59 +0100 Subject: [PATCH] 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{}