Compare commits
11 Commits
main
...
feat-proje
| Author | SHA1 | Date |
|---|---|---|
|
|
0c8cc8e32d | |
|
|
3061b8117a | |
|
|
b3d5eb01dc | |
|
|
00645f50b6 | |
|
|
3de5206dd4 | |
|
|
6b6ca25efa | |
|
|
692a6d623d | |
|
|
d7f196be75 | |
|
|
ab269afd61 | |
|
|
2b4efef119 | |
|
|
499941ed39 |
|
|
@ -49,6 +49,16 @@
|
||||||
{{ $t('project.projects') }}
|
{{ $t('project.projects') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'templates.index'}"
|
||||||
|
>
|
||||||
|
<span class="menu-item-icon icon">
|
||||||
|
<Icon icon="copy" />
|
||||||
|
</span>
|
||||||
|
{{ $t('project.template.title') }}
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-shortcut="'KeyG KeyA'"
|
v-shortcut="'KeyG KeyA'"
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,13 @@
|
||||||
>
|
>
|
||||||
{{ $t('menu.duplicate') }}
|
{{ $t('menu.duplicate') }}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
v-if="!project.isTemplate"
|
||||||
|
icon="copy"
|
||||||
|
@click="saveAsTemplate"
|
||||||
|
>
|
||||||
|
{{ $t('project.template.saveAsTemplate') }}
|
||||||
|
</DropdownItem>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''"
|
v-tooltip="isDefaultProject ? $t('menu.cantArchiveIsDefault') : ''"
|
||||||
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
|
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
|
||||||
|
|
@ -140,6 +147,9 @@ import {useConfigStore} from '@/stores/config'
|
||||||
import {useProjectStore} from '@/stores/projects'
|
import {useProjectStore} from '@/stores/projects'
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
import {PERMISSIONS} from '@/constants/permissions'
|
import {PERMISSIONS} from '@/constants/permissions'
|
||||||
|
import ProjectTemplateService from '@/services/projectTemplateService'
|
||||||
|
import {success} from '@/message'
|
||||||
|
import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
project: IProject
|
project: IProject
|
||||||
|
|
@ -168,4 +178,16 @@ function setSubscriptionInStore(sub: ISubscription) {
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const isDefaultProject = computed(() => props.project?.id === authStore.settings.defaultProjectId)
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,15 @@
|
||||||
"text": "Select a parent project which should hold the duplicated project:",
|
"text": "Select a parent project which should hold the duplicated project:",
|
||||||
"success": "The project was successfully duplicated."
|
"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": {
|
"edit": {
|
||||||
"header": "Edit This Project",
|
"header": "Edit This Project",
|
||||||
"title": "Edit \"{project}\"",
|
"title": "Edit \"{project}\"",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface IProject extends IAbstract {
|
||||||
owner: IUser
|
owner: IUser
|
||||||
tasks: ITask[]
|
tasks: ITask[]
|
||||||
isArchived: boolean
|
isArchived: boolean
|
||||||
|
isTemplate: boolean
|
||||||
hexColor: string
|
hexColor: string
|
||||||
identifier: string
|
identifier: string
|
||||||
backgroundInformation: unknown | null // FIXME: improve type
|
backgroundInformation: unknown | null // FIXME: improve type
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
|
||||||
owner: IUser = UserModel
|
owner: IUser = UserModel
|
||||||
tasks: ITask[] = []
|
tasks: ITask[] = []
|
||||||
isArchived = false
|
isArchived = false
|
||||||
|
isTemplate = false
|
||||||
hexColor = ''
|
hexColor = ''
|
||||||
identifier = ''
|
identifier = ''
|
||||||
backgroundInformation: unknown | null = null
|
backgroundInformation: unknown | null = null
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,11 @@ const router = createRouter({
|
||||||
name: 'projects.index',
|
name: 'projects.index',
|
||||||
component: () => import('@/views/project/ListProjects.vue'),
|
component: () => import('@/views/project/ListProjects.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/templates',
|
||||||
|
name: 'templates.index',
|
||||||
|
component: () => import('@/views/project/ListTemplates.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/projects/new',
|
path: '/projects/new',
|
||||||
name: 'project.create',
|
name: 'project.create',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import AbstractService from './abstractService'
|
||||||
|
|
||||||
|
export default class ProjectTemplateService extends AbstractService {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
create: '/projects/{projectId}/template',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,14 +37,17 @@ export const useProjectStore = defineStore('project', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const notArchivedRootProjects = computed(() => projectsArray.value
|
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)
|
p.parentProjectId === 0 || isOrphanedSubProject(p)
|
||||||
)))
|
)))
|
||||||
const favoriteProjects = computed(() => projectsArray.value
|
const favoriteProjects = computed(() => projectsArray.value
|
||||||
.filter(p => !p.isArchived && p.isFavorite))
|
.filter(p => !p.isArchived && !p.isTemplate && p.isFavorite))
|
||||||
const savedFilterProjects = computed(() => projectsArray.value
|
const savedFilterProjects = computed(() => projectsArray.value
|
||||||
.filter(p => !p.isArchived && p.id < -1))
|
.filter(p => !p.isArchived && p.id < -1))
|
||||||
const hasProjects = computed(() => projectsArray.value.length > 0)
|
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(() => {
|
const getChildProjects = computed(() => {
|
||||||
return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
|
return (id: IProject['id']) => projectsArray.value.filter(p => p.parentProjectId === id)
|
||||||
|
|
@ -261,7 +264,7 @@ export const useProjectStore = defineStore('project', () => {
|
||||||
let page = 1
|
let page = 1
|
||||||
try {
|
try {
|
||||||
do {
|
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)
|
loadedProjects.push(...newProjects)
|
||||||
page++
|
page++
|
||||||
} while (page <= projectService.totalPages)
|
} while (page <= projectService.totalPages)
|
||||||
|
|
@ -329,6 +332,8 @@ export const useProjectStore = defineStore('project', () => {
|
||||||
favoriteProjects: readonly(favoriteProjects),
|
favoriteProjects: readonly(favoriteProjects),
|
||||||
hasProjects: readonly(hasProjects),
|
hasProjects: readonly(hasProjects),
|
||||||
savedFilterProjects: readonly(savedFilterProjects),
|
savedFilterProjects: readonly(savedFilterProjects),
|
||||||
|
templateProjects: readonly(templateProjects),
|
||||||
|
hasTemplates: readonly(hasTemplates),
|
||||||
|
|
||||||
getChildProjects,
|
getChildProjects,
|
||||||
isOrphanedSubProject,
|
isOrphanedSubProject,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -5,6 +5,18 @@
|
||||||
:primary-disabled="project.title === ''"
|
:primary-disabled="project.title === ''"
|
||||||
@create="createProject()"
|
@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
|
<FormField
|
||||||
v-model="project.title"
|
v-model="project.title"
|
||||||
v-focus
|
v-focus
|
||||||
|
|
@ -31,14 +43,18 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 {useI18n} from 'vue-i18n'
|
||||||
|
import {useRouter} from 'vue-router'
|
||||||
|
|
||||||
import ProjectService from '@/services/project'
|
import ProjectService from '@/services/project'
|
||||||
import ProjectModel from '@/models/project'
|
import ProjectModel from '@/models/project'
|
||||||
|
import ProjectDuplicateService from '@/services/projectDuplicateService'
|
||||||
|
import ProjectDuplicateModel from '@/models/projectDuplicateModel'
|
||||||
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
||||||
import ColorPicker from '@/components/input/ColorPicker.vue'
|
import ColorPicker from '@/components/input/ColorPicker.vue'
|
||||||
import FormField from '@/components/input/FormField.vue'
|
import FormField from '@/components/input/FormField.vue'
|
||||||
|
import Multiselect from '@/components/input/Multiselect.vue'
|
||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
|
@ -51,6 +67,7 @@ const props = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
const {t} = useI18n({useScope: 'global'})
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useTitle(() => t('project.create.header'))
|
useTitle(() => t('project.create.header'))
|
||||||
|
|
||||||
|
|
@ -60,6 +77,9 @@ const projectService = shallowReactive(new ProjectService())
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const parentProject = ref<IProject | null>(null)
|
const parentProject = ref<IProject | null>(null)
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
|
const selectedTemplate = ref<IProject | null>(null)
|
||||||
|
|
||||||
|
const templateOptions = computed(() => projectStore.templateProjects as IProject[])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.parentProjectId,
|
() => props.parentProjectId,
|
||||||
|
|
@ -85,8 +105,28 @@ async function createProject() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await projectStore.createProject(project)
|
if (selectedTemplate.value) {
|
||||||
success({message: t('project.create.createdSuccess')})
|
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 {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -393,3 +393,25 @@
|
||||||
position: 4300
|
position: 4300
|
||||||
updated: 2018-12-02 15:13:12
|
updated: 2018-12-02 15:13:12
|
||||||
created: 2018-12-01 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
|
||||||
|
|
|
||||||
|
|
@ -118,3 +118,9 @@
|
||||||
permission: 0
|
permission: 0
|
||||||
updated: 2018-12-02 15:13:12
|
updated: 2018-12-02 15:13:12
|
||||||
created: 2018-12-01 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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
|
// Task errors
|
||||||
// ==============
|
// ==============
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,9 @@ type Project struct {
|
||||||
// Whether a project is archived.
|
// Whether a project is archived.
|
||||||
IsArchived bool `xorm:"not null default false" json:"is_archived" query:"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
|
// The id of the file this project has set as background
|
||||||
BackgroundFileID int64 `xorm:"null" json:"-"`
|
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
|
// 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 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 s query string false "Search projects by title."
|
||||||
// @Param is_archived query bool false "If true, also returns all archived projects."
|
// @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`."
|
// @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
|
// @Security JWTKeyAuth
|
||||||
// @Success 200 {array} models.Project "The projects"
|
// @Success 200 {array} models.Project "The projects"
|
||||||
|
|
@ -175,7 +179,7 @@ var FavoritesPseudoProject = Project{
|
||||||
// @Failure 500 {object} models.Message "Internal error"
|
// @Failure 500 {object} models.Message "Internal error"
|
||||||
// @Router /projects [get]
|
// @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) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
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
|
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 {
|
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
|
// 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(
|
prs, resultCount, totalItems, err := getRawProjectsForUser(
|
||||||
s,
|
s,
|
||||||
&projectOptions{
|
&projectOptions{
|
||||||
search: search,
|
search: search,
|
||||||
user: doer,
|
user: doer,
|
||||||
page: page,
|
page: page,
|
||||||
perPage: perPage,
|
perPage: perPage,
|
||||||
getArchived: isArchived,
|
getArchived: isArchived,
|
||||||
|
getTemplates: isTemplate,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
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.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, 0, 0, err
|
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
|
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)
|
limit, start := getLimitFromPageIndex(page, perPage)
|
||||||
|
|
||||||
conds := []builder.Cond{}
|
conds := []builder.Cond{}
|
||||||
if !isArchived {
|
if !isArchived {
|
||||||
conds = append(conds, builder.Eq{"is_archived": false})
|
conds = append(conds, builder.Eq{"is_archived": false})
|
||||||
}
|
}
|
||||||
|
if !isTemplate {
|
||||||
|
conds = append(conds, builder.Eq{"is_template": false})
|
||||||
|
}
|
||||||
if search != "" {
|
if search != "" {
|
||||||
ids := []int64{}
|
ids := []int64{}
|
||||||
for _, val := range strings.Split(search, ",") {
|
for _, val := range strings.Split(search, ",") {
|
||||||
|
|
@ -525,11 +533,12 @@ func GetProjectsByIDs(s *xorm.Session, projectIDs []int64) (projects []*Project,
|
||||||
}
|
}
|
||||||
|
|
||||||
type projectOptions struct {
|
type projectOptions struct {
|
||||||
search string
|
search string
|
||||||
user *user.User
|
user *user.User
|
||||||
page int
|
page int
|
||||||
perPage int
|
perPage int
|
||||||
getArchived bool
|
getArchived bool
|
||||||
|
getTemplates bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserProjectsStatement(userID int64, search string) *builder.Builder {
|
func getUserProjectsStatement(userID int64, search string) *builder.Builder {
|
||||||
|
|
@ -584,7 +593,7 @@ func getUserProjectsStatement(userID int64, search string) *builder.Builder {
|
||||||
}
|
}
|
||||||
|
|
||||||
return 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").
|
From("projects", "l").
|
||||||
Join("LEFT", "team_projects tl", "tl.project_id = l.id").
|
Join("LEFT", "team_projects tl", "tl.project_id = l.id").
|
||||||
Join("LEFT", "team_members tm2", "tm2.team_id = tl.team_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 + `
|
baseQuery := querySQLString + `
|
||||||
UNION ALL
|
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`
|
INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
|
||||||
|
|
||||||
columnStr := strings.Join([]string{
|
columnStr := strings.Join([]string{
|
||||||
|
|
@ -661,6 +670,7 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
|
||||||
"all_projects.owner_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",
|
"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_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_file_id",
|
||||||
"all_projects.background_blur_hash",
|
"all_projects.background_blur_hash",
|
||||||
"all_projects.position",
|
"all_projects.position",
|
||||||
|
|
@ -683,15 +693,22 @@ INNER JOIN all_projects ap ON p.parent_project_id = ap.id`
|
||||||
"all_projects.updated",
|
"all_projects.updated",
|
||||||
}, ", ")
|
}, ", ")
|
||||||
|
|
||||||
var archivedFilter string
|
var havingClauses []string
|
||||||
if !opts.getArchived {
|
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{}
|
currentProjects := []*Project{}
|
||||||
err = s.SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`)
|
err = s.SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`)
|
||||||
SELECT `+columnStr+` FROM all_projects
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -702,7 +719,7 @@ GROUP BY `+groupByStr+` `+archivedFilter+`ORDER BY all_projects.position `+limit
|
||||||
|
|
||||||
totalCount, err = s.
|
totalCount, err = s.
|
||||||
SQL(`WITH RECURSIVE all_projects as (`+baseQuery+`)
|
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{})
|
Count(&Project{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
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}
|
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
|
// Check if the parent project exists
|
||||||
if project.ParentProjectID > 0 {
|
if project.ParentProjectID > 0 {
|
||||||
if project.ParentProjectID == project.ID {
|
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)
|
err = setArchiveStateForProjectDescendants(s, project.ID, project.IsArchived)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -1157,6 +1189,7 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje
|
||||||
colsToUpdate := []string{
|
colsToUpdate := []string{
|
||||||
"title",
|
"title",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
|
"is_template",
|
||||||
"identifier",
|
"identifier",
|
||||||
"hex_color",
|
"hex_color",
|
||||||
"parent_project_id",
|
"parent_project_id",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ type ProjectDuplicate struct {
|
||||||
// The copied project
|
// The copied project
|
||||||
Project *Project `json:"duplicated_project,omitempty"`
|
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.Permissions `json:"-"`
|
||||||
web.CRUDable `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
|
pd.Project.ParentProjectID = pd.ParentProjectID
|
||||||
// Set the owner to the current user
|
// Set the owner to the current user
|
||||||
pd.Project.OwnerID = doer.GetID()
|
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)
|
err = CreateProject(s, pd.Project, doer, false, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If there is no available unique project identifier, just reset it.
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions / Shares
|
if !pd.SkipPermissions {
|
||||||
// To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent
|
// Permissions / Shares
|
||||||
users := []*ProjectUser{}
|
// To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent
|
||||||
err = s.Where("project_id = ?", pd.ProjectID).Find(&users)
|
users := []*ProjectUser{}
|
||||||
if err != nil {
|
err = s.Where("project_id = ?", pd.ProjectID).Find(&users)
|
||||||
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 err != nil {
|
if err != nil {
|
||||||
return err
|
return
|
||||||
}
|
}
|
||||||
share.Hash = hash
|
for _, u := range users {
|
||||||
if _, err := s.Insert(share); err != nil {
|
u.ID = 0
|
||||||
return err
|
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)
|
err = pd.Project.ReadOne(s, doer)
|
||||||
return
|
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)
|
log.Debugf("Duplicated all labels from project %d into %d", ld.ProjectID, ld.Project.ID)
|
||||||
|
|
||||||
// Assignees
|
if !ld.SkipAssigneesAndComments {
|
||||||
// Only copy those assignees who have access to the task
|
// Assignees
|
||||||
assignees := []*TaskAssginee{}
|
// Only copy those assignees who have access to the task
|
||||||
err = s.In("task_id", oldTaskIDs).Find(&assignees)
|
assignees := []*TaskAssginee{}
|
||||||
if err != nil {
|
err = s.In("task_id", oldTaskIDs).Find(&assignees)
|
||||||
return
|
if err != nil {
|
||||||
}
|
return
|
||||||
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 {
|
for _, a := range assignees {
|
||||||
if IsErrUserDoesNotHaveAccessToProject(err) {
|
t := &Task{
|
||||||
continue
|
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
|
||||||
comments := []*TaskComment{}
|
comments := []*TaskComment{}
|
||||||
err = s.In("task_id", oldTaskIDs).Find(&comments)
|
err = s.In("task_id", oldTaskIDs).Find(&comments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, c := range comments {
|
for _, c := range comments {
|
||||||
c.ID = 0
|
c.ID = 0
|
||||||
c.TaskID = newTaskIDs[c.TaskID]
|
c.TaskID = newTaskIDs[c.TaskID]
|
||||||
if _, err := s.Insert(c); err != nil {
|
if _, err := s.Insert(c); err != nil {
|
||||||
return nil, err
|
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
|
// Relations in that project
|
||||||
// Low-Effort: Only copy those relations which are between tasks in the same project
|
// Low-Effort: Only copy those relations which are between tasks in the same project
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -604,6 +604,13 @@ func registerAPIRoutes(a *echo.Group) {
|
||||||
}
|
}
|
||||||
a.PUT("/projects/:projectid/duplicate", projectDuplicateHandler.CreateWeb)
|
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{
|
taskHandler := &handler.WebHandler{
|
||||||
EmptyStruct: func() handler.CObject {
|
EmptyStruct: func() handler.CObject {
|
||||||
return &models.Task{}
|
return &models.Task{}
|
||||||
|
|
|
||||||
|
|
@ -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("ReadOne", func(t *testing.T) {
|
||||||
t.Run("Normal", 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":`)
|
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`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue