feat(projects): make duplicating shares opt-in (#2932)
This commit is contained in:
parent
bf175dde6d
commit
f3c6312a9e
|
|
@ -393,6 +393,7 @@
|
||||||
"title": "Duplicate this project",
|
"title": "Duplicate this project",
|
||||||
"label": "Duplicate",
|
"label": "Duplicate",
|
||||||
"text": "Select a parent project which should hold the duplicated project:",
|
"text": "Select a parent project which should hold the duplicated project:",
|
||||||
|
"shares": "Copy shares (users, teams and link shares) to the duplicate",
|
||||||
"success": "The project was successfully duplicated."
|
"success": "The project was successfully duplicated."
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract {
|
||||||
projectId: number
|
projectId: number
|
||||||
duplicatedProject: IProject | null
|
duplicatedProject: IProject | null
|
||||||
parentProjectId: IProject['id']
|
parentProjectId: IProject['id']
|
||||||
|
duplicateShares: boolean
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplica
|
||||||
projectId = 0
|
projectId = 0
|
||||||
duplicatedProject: IProject | null = null
|
duplicatedProject: IProject | null = null
|
||||||
parentProjectId = 0
|
parentProjectId = 0
|
||||||
|
duplicateShares = false
|
||||||
|
|
||||||
constructor(data : Partial<IProjectDuplicate>) {
|
constructor(data : Partial<IProjectDuplicate>) {
|
||||||
super()
|
super()
|
||||||
|
|
|
||||||
|
|
@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter<IProject['id']>) {
|
||||||
success({message: t('project.edit.success')})
|
success({message: t('project.edit.success')})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function duplicateProject(parentProjectId: IProject['id']) {
|
async function duplicateProject(parentProjectId: IProject['id'], duplicateShares: boolean = false) {
|
||||||
const projectDuplicate = new ProjectDuplicateModel({
|
const projectDuplicate = new ProjectDuplicateModel({
|
||||||
projectId: Number(toValue(projectId)),
|
projectId: Number(toValue(projectId)),
|
||||||
parentProjectId,
|
parentProjectId,
|
||||||
|
duplicateShares,
|
||||||
})
|
})
|
||||||
|
|
||||||
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
const duplicate = await projectDuplicateService.create(projectDuplicate)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@
|
||||||
>
|
>
|
||||||
<p>{{ $t('project.duplicate.text') }}</p>
|
<p>{{ $t('project.duplicate.text') }}</p>
|
||||||
<ProjectSearch v-model="parentProject" />
|
<ProjectSearch v-model="parentProject" />
|
||||||
|
<FancyCheckbox
|
||||||
|
v-model="duplicateShares"
|
||||||
|
class="mbs-2"
|
||||||
|
>
|
||||||
|
{{ $t('project.duplicate.shares') }}
|
||||||
|
</FancyCheckbox>
|
||||||
</CreateEdit>
|
</CreateEdit>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -18,6 +24,7 @@ import {useI18n} from 'vue-i18n'
|
||||||
|
|
||||||
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
import CreateEdit from '@/components/misc/CreateEdit.vue'
|
||||||
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
|
||||||
|
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
|
||||||
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
import {useTitle} from '@/composables/useTitle'
|
import {useTitle} from '@/composables/useTitle'
|
||||||
|
|
@ -33,6 +40,7 @@ const projectStore = useProjectStore()
|
||||||
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
|
||||||
|
|
||||||
const parentProject = ref<IProject | null>(null)
|
const parentProject = ref<IProject | null>(null)
|
||||||
|
const duplicateShares = ref(true)
|
||||||
const isDuplicating = ref(false)
|
const isDuplicating = ref(false)
|
||||||
|
|
||||||
const loadingModel = computed({
|
const loadingModel = computed({
|
||||||
|
|
@ -53,7 +61,7 @@ async function duplicate() {
|
||||||
isDuplicating.value = true
|
isDuplicating.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await duplicateProject(parentProject.value?.id ?? 0)
|
await duplicateProject(parentProject.value?.id ?? 0, duplicateShares.value)
|
||||||
success({message: t('project.duplicate.success')})
|
success({message: t('project.duplicate.success')})
|
||||||
} finally {
|
} finally {
|
||||||
isDuplicating.value = false
|
isDuplicating.value = false
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ type ProjectDuplicate struct {
|
||||||
ProjectID int64 `json:"-" param:"projectid"`
|
ProjectID int64 `json:"-" param:"projectid"`
|
||||||
// The target parent project
|
// The target parent project
|
||||||
ParentProjectID int64 `json:"parent_project_id,omitempty" doc:"The id of the project under which the duplicate should be created. Omit or 0 to place the copy at the top level; you need write access to the parent."`
|
ParentProjectID int64 `json:"parent_project_id,omitempty" doc:"The id of the project under which the duplicate should be created. Omit or 0 to place the copy at the top level; you need write access to the parent."`
|
||||||
|
// Whether to copy the project's shares to the duplicate
|
||||||
|
DuplicateShares bool `json:"duplicate_shares,omitempty" doc:"Whether to copy the project's user, team and link shares to the duplicate. Defaults to false."`
|
||||||
|
|
||||||
// The copied project
|
// The copied project
|
||||||
Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."`
|
Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."`
|
||||||
|
|
@ -62,7 +64,7 @@ func (pd *ProjectDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bo
|
||||||
|
|
||||||
// Create duplicates a project
|
// Create duplicates a project
|
||||||
// @Summary Duplicate an existing project
|
// @Summary Duplicate an existing project
|
||||||
// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.
|
// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.
|
||||||
// @tags project
|
// @tags project
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|
@ -117,56 +119,58 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permissions / Shares
|
if pd.DuplicateShares {
|
||||||
// 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
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import (
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"xorm.io/xorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProjectDuplicate(t *testing.T) {
|
func TestProjectDuplicate(t *testing.T) {
|
||||||
|
|
@ -38,6 +39,54 @@ func TestProjectDuplicate(t *testing.T) {
|
||||||
// (non-Unsplash) background would fail with an internal server error
|
// (non-Unsplash) background would fail with an internal server error
|
||||||
testProjectDuplicate(t, 35, 6)
|
testProjectDuplicate(t, 35, 6)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("shares are not copied by default", func(t *testing.T) {
|
||||||
|
files.InitTestFileFixtures(t)
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
// Project 3 has user, team and link shares
|
||||||
|
u := &user.User{ID: 3}
|
||||||
|
l := &ProjectDuplicate{ProjectID: 3}
|
||||||
|
can, err := l.CanCreate(s, u)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, can)
|
||||||
|
require.NoError(t, l.Create(s, u))
|
||||||
|
|
||||||
|
assertShareCount(t, s, l.Project.ID, 0, 0, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("shares are copied when duplicate_shares is set", func(t *testing.T) {
|
||||||
|
files.InitTestFileFixtures(t)
|
||||||
|
db.LoadAndAssertFixtures(t)
|
||||||
|
s := db.NewSession()
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
// Project 3 has 2 user shares, 1 team share and 1 link share
|
||||||
|
u := &user.User{ID: 3}
|
||||||
|
l := &ProjectDuplicate{ProjectID: 3, DuplicateShares: true}
|
||||||
|
can, err := l.CanCreate(s, u)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, can)
|
||||||
|
require.NoError(t, l.Create(s, u))
|
||||||
|
|
||||||
|
assertShareCount(t, s, l.Project.ID, 2, 1, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertShareCount(t *testing.T, s *xorm.Session, projectID, users, teams, links int64) {
|
||||||
|
userCount, err := s.Where("project_id = ?", projectID).Count(&ProjectUser{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, users, userCount, "unexpected number of user shares")
|
||||||
|
|
||||||
|
teamCount, err := s.Where("project_id = ?", projectID).Count(&TeamProject{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, teams, teamCount, "unexpected number of team shares")
|
||||||
|
|
||||||
|
linkCount, err := s.Where("project_id = ?", projectID).Count(&LinkSharing{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, links, linkCount, "unexpected number of link shares")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
|
func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
|
||||||
|
|
@ -51,7 +100,8 @@ func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
|
||||||
}
|
}
|
||||||
|
|
||||||
l := &ProjectDuplicate{
|
l := &ProjectDuplicate{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
|
DuplicateShares: true,
|
||||||
}
|
}
|
||||||
can, err := l.CanCreate(s, u)
|
can, err := l.CanCreate(s, u)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ func RegisterProjectDuplicateRoutes(api huma.API) {
|
||||||
Register(api, huma.Operation{
|
Register(api, huma.Operation{
|
||||||
OperationID: "projects-duplicate",
|
OperationID: "projects-duplicate",
|
||||||
Summary: "Duplicate a project",
|
Summary: "Duplicate a project",
|
||||||
Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds and user/team/link shares — into a new project owned by the authenticated user. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.",
|
Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds — into a new project owned by the authenticated user. User/team/link shares are only copied when duplicate_shares is set to true. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Path: "/projects/{projectid}/duplicate",
|
Path: "/projects/{projectid}/duplicate",
|
||||||
Tags: tags,
|
Tags: tags,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue