Compare commits

...

11 Commits

Author SHA1 Message Date
kolaente 0c8cc8e32d 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.
2026-05-31 17:45:32 +02:00
kolaente 3061b8117a fix(templates): use aggregate for is_template HAVING clause for MySQL/PostgreSQL compatibility 2026-05-31 17:45:32 +02:00
kolaente b3d5eb01dc fix(templates): address review feedback — templates page, proper service, fetch together with archived 2026-05-31 17:44:45 +02:00
kolaente 00645f50b6 fix(templates): reuse ProjectDuplicate.Create for template creation and fix is_template filter semantics 2026-05-31 17:44:45 +02:00
kolaente 3de5206dd4 feat(templates): add templates sidebar, template picker, save-as-template action, and i18n keys 2026-05-31 17:44:45 +02:00
kolaente 6b6ca25efa feat(templates): add isTemplate to frontend project model and store 2026-05-31 17:44:45 +02:00
kolaente 692a6d623d test(templates): add web tests for template filtering and save-as-template 2026-05-31 17:44:45 +02:00
kolaente d7f196be75 feat(templates): add template project test fixtures 2026-05-31 17:44:45 +02:00
kolaente ab269afd61 feat(templates): add save-as-template endpoint 2026-05-31 17:42:14 +02:00
kolaente 2b4efef119 feat(templates): add IsTemplate field to Project model with filtering and validation 2026-05-31 17:42:14 +02:00
kolaente 499941ed39 feat(templates): add is_template column migration 2026-05-31 17:33:55 +02:00
19 changed files with 573 additions and 106 deletions

View File

@ -49,6 +49,16 @@
{{ $t('project.projects') }}
</RouterLink>
</li>
<li>
<RouterLink
:to="{ name: 'templates.index'}"
>
<span class="menu-item-icon icon">
<Icon icon="copy" />
</span>
{{ $t('project.template.title') }}
</RouterLink>
</li>
<li>
<RouterLink
v-shortcut="'KeyG KeyA'"

View File

@ -81,6 +81,13 @@
>
{{ $t('menu.duplicate') }}
</DropdownItem>
<DropdownItem
v-if="!project.isTemplate"
icon="copy"
@click="saveAsTemplate"
>
{{ $t('project.template.saveAsTemplate') }}
</DropdownItem>
<DropdownItem
v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''"
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
@ -140,6 +147,9 @@ import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {PERMISSIONS} from '@/constants/permissions'
import ProjectTemplateService from '@/services/projectTemplateService'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
const props = withDefaults(defineProps<{
project: IProject
@ -168,4 +178,16 @@ function setSubscriptionInStore(sub: ISubscription) {
const authStore = useAuthStore()
const isDefaultProject = computed(() => props.project?.id === authStore.settings.defaultProjectId)
const {t} = useI18n({useScope: 'global'})
async function saveAsTemplate() {
const templateService = new ProjectTemplateService()
const response = await templateService.create({projectId: props.project.id})
if (response.project) {
projectStore.setProject(response.project)
}
await projectStore.loadAllProjects()
success({message: t('project.template.saveAsTemplateSuccess')})
}
</script>

View File

@ -394,6 +394,15 @@
"text": "Select a parent project which should hold the duplicated project:",
"success": "The project was successfully duplicated."
},
"template": {
"title": "Templates",
"saveAsTemplate": "Save as Template",
"saveAsTemplateSuccess": "Project saved as template successfully.",
"useTemplate": "Use a template",
"selectTemplate": "Select a template\u2026",
"createFromTemplate": "Creating project from template\u2026",
"none": "You don't have any templates yet. Save a project as a template to get started."
},
"edit": {
"header": "Edit This Project",
"title": "Edit \"{project}\"",

View File

@ -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

View File

@ -17,6 +17,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
owner: IUser = UserModel
tasks: ITask[] = []
isArchived = false
isTemplate = false
hexColor = ''
identifier = ''
backgroundInformation: unknown | null = null

View File

@ -241,6 +241,11 @@ const router = createRouter({
name: 'projects.index',
component: () => import('@/views/project/ListProjects.vue'),
},
{
path: '/templates',
name: 'templates.index',
component: () => import('@/views/project/ListTemplates.vue'),
},
{
path: '/projects/new',
name: 'project.create',

View File

@ -0,0 +1,9 @@
import AbstractService from './abstractService'
export default class ProjectTemplateService extends AbstractService {
constructor() {
super({
create: '/projects/{projectId}/template',
})
}
}

View File

@ -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)
@ -261,15 +264,15 @@ export const useProjectStore = defineStore('project', () => {
let page = 1
try {
do {
const newProjects = await projectService.getAll({}, {is_archived: true, expand: 'permissions'}, page) as IProject[]
const newProjects = await projectService.getAll({}, {is_archived: true, is_template: true, expand: 'permissions'}, page) as IProject[]
loadedProjects.push(...newProjects)
page++
} while (page <= projectService.totalPages)
} finally {
cancel()
}
projects.value = {}
setProjects(loadedProjects)
@ -329,6 +332,8 @@ export const useProjectStore = defineStore('project', () => {
favoriteProjects: readonly(favoriteProjects),
hasProjects: readonly(hasProjects),
savedFilterProjects: readonly(savedFilterProjects),
templateProjects: readonly(templateProjects),
hasTemplates: readonly(hasTemplates),
getChildProjects,
isOrphanedSubProject,

View File

@ -0,0 +1,48 @@
<template>
<div
class="content loader-container"
:class="{'is-loading': loading}"
>
<header class="project-header">
<h3>{{ $t('project.template.title') }}</h3>
</header>
<p v-if="!loading && templates.length === 0">
{{ $t('project.template.none') }}
</p>
<ProjectCardGrid
v-else
:projects="templates"
:show-archived="false"
/>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import ProjectCardGrid from '@/components/project/partials/ProjectCardGrid.vue'
import {useTitle} from '@/composables/useTitle'
import {useProjectStore} from '@/stores/projects'
import type {IProject} from '@/modelTypes/IProject'
const {t} = useI18n()
const projectStore = useProjectStore()
useTitle(() => t('project.template.title'))
const loading = computed(() => projectStore.isLoading)
const templates = computed(() => projectStore.templateProjects as IProject[])
</script>
<style lang="scss" scoped>
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-block-end: 1rem;
}
</style>

View File

@ -5,6 +5,18 @@
:primary-disabled="project.title === ''"
@create="createProject()"
>
<FormField
v-if="projectStore.hasTemplates"
:label="$t('project.template.useTemplate')"
>
<Multiselect
v-model="selectedTemplate"
:options="templateOptions"
:placeholder="$t('project.template.selectTemplate')"
label="title"
track-by="id"
/>
</FormField>
<FormField
v-model="project.title"
v-focus
@ -31,14 +43,18 @@
</template>
<script setup lang="ts">
import {ref, reactive, shallowReactive, watch} from 'vue'
import {ref, reactive, shallowReactive, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import ProjectService from '@/services/project'
import ProjectModel from '@/models/project'
import ProjectDuplicateService from '@/services/projectDuplicateService'
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
import CreateEdit from '@/components/misc/CreateEdit.vue'
import ColorPicker from '@/components/input/ColorPicker.vue'
import FormField from '@/components/input/FormField.vue'
import Multiselect from '@/components/input/Multiselect.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
@ -51,6 +67,7 @@ const props = defineProps<{
}>()
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
useTitle(() => t('project.create.header'))
@ -60,6 +77,9 @@ const projectService = shallowReactive(new ProjectService())
const projectStore = useProjectStore()
const parentProject = ref<IProject | null>(null)
const isSubmitting = ref(false)
const selectedTemplate = ref<IProject | null>(null)
const templateOptions = computed(() => projectStore.templateProjects as IProject[])
watch(
() => props.parentProjectId,
@ -85,8 +105,28 @@ async function createProject() {
}
try {
await projectStore.createProject(project)
success({message: t('project.create.createdSuccess')})
if (selectedTemplate.value) {
const duplicateService = new ProjectDuplicateService()
const duplicate = new ProjectDuplicateModel({
projectId: selectedTemplate.value.id,
parentProjectId: project.parentProjectId,
})
const response = await duplicateService.create(duplicate)
const newProject = response.duplicatedProject
if (newProject) {
if (project.title !== selectedTemplate.value.title) {
const updatedProject = await projectService.update({...newProject, title: project.title})
projectStore.setProject(updatedProject)
} else {
projectStore.setProject(newProject)
}
router.push({name: 'project.index', params: {projectId: newProject.id}})
}
success({message: t('project.create.createdSuccess')})
} else {
await projectStore.createProject(project)
success({message: t('project.create.createdSuccess')})
}
} finally {
isSubmitting.value = false
}

View File

@ -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

View File

@ -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

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View File

@ -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
// ==============

View File

@ -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, 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"
@ -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 {
@ -584,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").
@ -649,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{
@ -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",
"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",
@ -683,15 +693,22 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
"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, "MAX(CASE WHEN all_projects.is_template THEN 1 ELSE 0 END) = 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(&currentProjects)
GROUP BY `+groupByStr+` `+havingFilter+`ORDER BY all_projects.position `+limitSQL, args...).Find(&currentProjects)
if err != nil {
return
}
@ -702,7 +719,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 +973,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 +1169,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 +1189,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",

View File

@ -38,6 +38,13 @@ 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:"-"`
// If true, the duplicated project will be marked as a template
IsTemplate bool `json:"-"`
web.Permissions `json:"-"`
web.CRUDable `json:"-"`
}
@ -85,7 +92,15 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
pd.Project.ParentProjectID = pd.ParentProjectID
// Set the owner to the current user
pd.Project.OwnerID = doer.GetID()
pd.Project.Title += " - duplicate"
if pd.IsTemplate {
pd.Project.IsTemplate = true
pd.Project.ParentProjectID = 0
pd.SkipPermissions = true
pd.SkipAssigneesAndComments = true
} else {
pd.Project.Title += " - duplicate"
}
err = CreateProject(s, pd.Project, doer, false, false)
if err != nil {
// If there is no available unique project identifier, just reset it.
@ -117,56 +132,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 +448,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

View File

@ -0,0 +1,79 @@
// 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 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 permission 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)
pd := &ProjectDuplicate{
ProjectID: pt.ProjectID,
IsTemplate: true,
}
// Read the source project so the duplicate has something to work with
pd.Project = &Project{ID: pt.ProjectID}
err = pd.Project.ReadOne(s, doer)
if err != nil {
return err
}
err = pd.Create(s, doer)
if err != nil {
return err
}
pt.Project = pd.Project
return
}

View File

@ -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{}

View File

@ -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("Including templates", 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.Contains(t, rec.Body.String(), `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`)
})
}