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') }}
|
||||
</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'"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}\"",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
.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,
|
||||
|
|
|
|||
|
|
@ -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 === ''"
|
||||
@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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ==============
|
||||
|
|
|
|||
|
|
@ -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(¤tProjects)
|
||||
GROUP BY `+groupByStr+` `+havingFilter+`ORDER BY all_projects.position `+limitSQL, args...).Find(¤tProjects)
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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{}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue