diff --git a/server/mergin/sync/commands.py b/server/mergin/sync/commands.py index 882b73b6..4262e60f 100644 --- a/server/mergin/sync/commands.py +++ b/server/mergin/sync/commands.py @@ -123,9 +123,7 @@ def remove(project_name): if not project: click.secho("ERROR: Project does not exist", fg="red", err=True) sys.exit(1) - project.removed_at = datetime.utcnow() - project.removed_by = None - db.session.commit() + project.schedule_deletion() click.secho("Project removed", fg="green") @project.command() diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index 3b32cda9..3817acd6 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -58,6 +58,7 @@ Storages = {"local": DiskStorage} project_deleted = signal("project_deleted") +project_soft_deleted = signal("project_soft_deleted") project_access_granted = signal("project_access_granted") push_finished = signal("push_finished") project_version_created = signal("project_version_created") @@ -295,6 +296,16 @@ def expiration(self) -> timedelta: initial = timedelta(days=current_app.config["DELETED_PROJECT_EXPIRATION"]) return initial - (datetime.utcnow() - self.removed_at) + def schedule_deletion(self, removed_by: int = None): + """Schedule project for removal (soft-delete). + Sets removed_at so the project is hidden from users but kept in db + until a background job permanently deletes it. + """ + self.removed_at = datetime.utcnow() + self.removed_by = removed_by + db.session.commit() + project_soft_deleted.send(self) + def delete(self, removed_by: int = None): """Mark project as permanently deleted (but keep in db) - rename (to free up the same name) diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py index 8dbe1237..34a2d28f 100644 --- a/server/mergin/sync/public_api_controller.py +++ b/server/mergin/sync/public_api_controller.py @@ -285,9 +285,7 @@ def delete_project(namespace, project_name): # noqa: E501 :rtype: None """ project = require_project(namespace, project_name, ProjectPermissions.Delete) - project.removed_at = datetime.utcnow() - project.removed_by = current_user.id - db.session.commit() + project.schedule_deletion(removed_by=current_user.id) return NoContent, 200 diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index ebd909ad..e7806865 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -76,9 +76,7 @@ def schedule_delete_project(id): rest. """ project = require_project_by_uuid(id, ProjectPermissions.Delete) - project.removed_at = datetime.utcnow() - project.removed_by = current_user.id - db.session.commit() + project.schedule_deletion(removed_by=current_user.id) return NoContent, 204 diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index d1f1afd6..1a0c76aa 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -37,6 +37,7 @@ FileHistory, PushChangeType, ProjectFilePath, + project_soft_deleted, ) from ..sync.storages.disk import copy_file as real_copy_file from ..sync.files import files_changes_from_upload @@ -2571,9 +2572,28 @@ def test_signals(client): project = create_project("test-project", workspace, user) with patch( "mergin.sync.public_api_controller.push_finished.send" - ) as push_finished_mock: + ) as push_finished_mock, patch( + "mergin.sync.public_api_controller.project_version_created.send" + ) as project_version_created_mock: upload_file_to_project(project, "test.txt", client) push_finished_mock.assert_called_once() + project_version_created_mock.assert_called_once() + + +def test_project_soft_delete(client): + """project.schedule_deletion() sets removed_at/removed_by and fires project_soft_deleted signal""" + workspace = create_workspace() + user = User.query.filter_by(username="mergin").first() + project = create_project("remove-test", workspace, user) + + with patch("mergin.sync.models.project_soft_deleted.send") as signal_mock: + project.schedule_deletion(removed_by=user.id) + signal_mock.assert_called_once_with(project) + + assert project.removed_at is not None + assert project.removed_by == user.id + # project storage params is still present (soft delete) + assert project.storage_params is not None def test_filepath_manipulation(client): diff --git a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue index ce54d755..c779d9e2 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue @@ -19,7 +19,7 @@ shape="circle" :pt="{ root: { - class: 'font-semibold text-color-forest' + class: 'font-semibold' } }" /> diff --git a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue index 4a975d9a..255e07cb 100644 --- a/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue +++ b/web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue @@ -6,6 +6,7 @@

Project details

- - + + @@ -129,15 +160,14 @@ import { defineComponent, ref, PropType } from 'vue' import { AppBreadcrumbs } from '.' -import { AppMenu, UserRouteName, useInstanceStore } from '@/main' +import { UserRouteName, useInstanceStore } from '@/main' import { useLayoutStore } from '@/modules/layout/store' import { useUserStore } from '@/modules/user/store' export default defineComponent({ name: 'app-header-template', components: { - AppBreadcrumbs, - AppMenu + AppBreadcrumbs }, props: { renderNamespace: { @@ -153,14 +183,23 @@ export default defineComponent({ }, setup() { const menu = ref() + const menuHelp = ref() + const menuVisible = ref(false) const toggleMenu = (event) => { menu.value.toggle(event) } + const toggleMenuHelp = (event) => { + menuHelp.value.toggle(event) + } + return { menu, - toggleMenu + menuHelp, + menuVisible, + toggleMenu, + toggleMenuHelp } }, computed: { @@ -215,6 +254,11 @@ export default defineComponent({ ? `${this.getUserFullName.substring(0, 15)}...` : this.getUserFullName }, + hasSidebar() { + return this.$route.matched.some( + (record) => record.components != null && 'sidebar' in record.components + ) + }, menuButtonIcon() { if (this.isUnderOverlayBreakpoint) { return 'ti ti-menu-2' diff --git a/web-app/packages/lib/src/modules/project/components/FileDetailSidebar.vue b/web-app/packages/lib/src/modules/project/components/FileDetailSidebar.vue index 4dcec5ee..b73cefbd 100644 --- a/web-app/packages/lib/src/modules/project/components/FileDetailSidebar.vue +++ b/web-app/packages/lib/src/modules/project/components/FileDetailSidebar.vue @@ -126,7 +126,7 @@ export default defineComponent({ }, file() { return ( - this.project?.files[this.filePath] || this.upload?.files[this.filePath] + this.project?.files?.[this.filePath] || this.upload?.files?.[this.filePath] ) }, fileName() { diff --git a/web-app/packages/lib/src/modules/project/components/FilesTable.vue b/web-app/packages/lib/src/modules/project/components/FilesTable.vue index 1409ccd7..8b8de543 100644 --- a/web-app/packages/lib/src/modules/project/components/FilesTable.vue +++ b/web-app/packages/lib/src/modules/project/components/FilesTable.vue @@ -181,7 +181,7 @@ const breadcrumps = computed(() => { }) const projectFiles = computed(() => { - let files = projectStore.project.files + let files = projectStore.project.files ?? [] if (projectStore.uploads[projectStore.project.path] && diff.value) { files = { ...files, diff --git a/web-app/packages/lib/src/modules/project/components/ProjectShareDialog.vue b/web-app/packages/lib/src/modules/project/components/ProjectShareDialog.vue index 9fac8ecc..04298c28 100644 --- a/web-app/packages/lib/src/modules/project/components/ProjectShareDialog.vue +++ b/web-app/packages/lib/src/modules/project/components/ProjectShareDialog.vue @@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial shape="circle" :pt="{ root: { - class: 'font-semibold text-color-forest mr-2', + class: 'font-semibold mr-2', style: { borderRadius: '50%' } diff --git a/web-app/packages/lib/src/modules/project/projectApi.ts b/web-app/packages/lib/src/modules/project/projectApi.ts index 657675c8..c17e4d5b 100644 --- a/web-app/packages/lib/src/modules/project/projectApi.ts +++ b/web-app/packages/lib/src/modules/project/projectApi.ts @@ -28,7 +28,8 @@ import { UpdateProjectCollaboratorPayload, UpdatePublicFlagParams, ProjectCollaborator, - AddProjectCollaboratorPayload + AddProjectCollaboratorPayload, + ProjectV2Response } from '@/modules/project/types' export const ProjectApi = { @@ -39,6 +40,10 @@ export const ProjectApi = { return ProjectModule.httpService(`/v1/project/${namespace}/${projectName}`) }, + async getProjectV2(id: string): Promise> { + return ProjectModule.httpService(`/v2/projects/${id}`) + }, + async createProject( namespace: string, data: CreateProjectParams, diff --git a/web-app/packages/lib/src/modules/project/store.ts b/web-app/packages/lib/src/modules/project/store.ts index 1edf1ae7..302b9a72 100644 --- a/web-app/packages/lib/src/modules/project/store.ts +++ b/web-app/packages/lib/src/modules/project/store.ts @@ -45,7 +45,8 @@ import { ProjectVersionListItem, UpdateProjectCollaboratorPayload, UpdatePublicFlagParams, - ProjectCollaborator + ProjectCollaborator, + ProjectV2Response } from '@/modules/project/types' import { useUserStore } from '@/modules/user/store' @@ -170,6 +171,29 @@ export const useProjectStore = defineStore('projectModule', { }) } }, + setProjectV2(payload: { project: ProjectV2Response }) { + this.project = { + ...(this.project ?? {}), + // Transofrm ProjectV2Response to EnhancedProjectDetail , we can skip files + id: payload.project.id, + name: payload.project.name, + namespace: payload.project.workspace.name, + workspace_id: payload.project.workspace.id, + role: payload.project.role, + path: [payload.project.workspace.name, payload.project.name].join('/'), + created: payload.project.created_at, + updated: payload.project.updated_at, + access: undefined, + version: payload.project.version, + // Leave as undefined in this time. When full implementation needed, we can use it. + files: undefined, + creator: undefined, + disk_usage: payload.project.size, + has_conflict: undefined, + permissions: undefined, + tags: undefined + } + }, setProjects(payload: ProjectsPayload) { this.projects = payload.projects this.projectsCount = payload.count @@ -489,6 +513,17 @@ export const useProjectStore = defineStore('projectModule', { } }, + async getProjectV2(projectId: string) { + const notificationStore = useNotificationStore() + + try { + const projectResponse = await ProjectApi.getProjectV2(projectId) + this.setProjectV2({ project: projectResponse.data }) + } catch { + await notificationStore.error({ text: 'Failed to load project data' }) + } + }, + async unsubscribeProject(payload) { const notificationStore = useNotificationStore() diff --git a/web-app/packages/lib/src/modules/project/types.ts b/web-app/packages/lib/src/modules/project/types.ts index e50e0492..169a4bfc 100644 --- a/web-app/packages/lib/src/modules/project/types.ts +++ b/web-app/packages/lib/src/modules/project/types.ts @@ -57,6 +57,21 @@ export interface Project { created?: string } +export interface ProjectV2Response { + id: string + name: string + version: string + public: boolean + size: number + created_at: string + updated_at: string + workspace: { + id: number + name: string + } + role: ProjectRoleName | null +} + export interface ProjectListItem extends Project { access: ProjectAccess creator: number | null diff --git a/web-app/packages/lib/src/modules/project/views/FileVersionDetailView.vue b/web-app/packages/lib/src/modules/project/views/FileVersionDetailView.vue index a41c7f32..403d229c 100644 --- a/web-app/packages/lib/src/modules/project/views/FileVersionDetailView.vue +++ b/web-app/packages/lib/src/modules/project/views/FileVersionDetailView.vue @@ -74,8 +74,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

No changeset for current layer

- console.log('getChangeset', this.namespace, this.projectName, - this.version_id, this.path) diff --git a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue index 86191186..136ee323 100644 --- a/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue +++ b/web-app/packages/lib/src/modules/user/views/ProfileViewTemplate.vue @@ -64,7 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial size="xlarge" :pt="{ root: { - class: 'font-semibold text-color-forest' + class: 'font-semibold' } }" />