Skip to content

Commit

Permalink
Merge pull request #1169 from swissgeol/GSNGM-816_duplicate_project
Browse files Browse the repository at this point in the history
Duplicate a project
  • Loading branch information
vladyslav-tk authored Dec 15, 2023
2 parents 8060981 + 0f11101 commit 24cfea1
Show file tree
Hide file tree
Showing 10 changed files with 100 additions and 57 deletions.
79 changes: 47 additions & 32 deletions api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use uuid::Uuid;

use crate::auth::Claims;
use crate::{Error, Result};
use anyhow::Context;
use rand::{distributions::Alphanumeric, Rng};
use serde_json::Number;
use std::collections::HashSet;
Expand Down Expand Up @@ -381,7 +382,7 @@ pub async fn list_projects(
#[axum_macros::debug_handler]
pub async fn duplicate_project(
Extension(pool): Extension<PgPool>,
Extension(_client): Extension<Client>,
Extension(client): Extension<Client>,
claims: Claims,
Json(project): Json<CreateProject>,
) -> Result<Json<Uuid>> {
Expand All @@ -394,7 +395,7 @@ pub async fn duplicate_project(
}

// Create project
let duplicate = Project {
let mut duplicate = Project {
id: Uuid::new_v4(),
title: project.title,
description: project.description,
Expand All @@ -410,30 +411,40 @@ pub async fn duplicate_project(
geometries: project.geometries,
};

// // TODO: make static
// let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap();

// for asset in &project.assets {
// let url = Url::parse(asset.href.as_str()).context("Failed to parse asset url")?;
// let path = &url.path()[1..];
// let name_opt = path.split('/').last();
// if !path.is_empty() && name_opt.is_some() {
// let new_path = format!("assets/{}/{}", project.id, name_opt.unwrap());
// client
// .copy_object()
// .copy_source(format!("{}/{}", &bucket, &path))
// .bucket(&bucket)
// .key(&new_path)
// .send()
// .await
// .context("Failed to copy object")?;
// assets.push(Asset {
// href: format!("https://download.swissgeol.ch/{new_path}"),
// });
// }
// }

// duplicate.assets = assets;
let mut assets: Vec<Asset> = Vec::new();
let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap();

for asset in &project.assets {
let generated_file_name: String = generate_asset_name();
let asset_key = format!("assets/saved/{}", asset.key);
let dest_key = format!("assets/saved/{}", generated_file_name);
// Check if the file exists in the source directory
let source_exists = client
.head_object()
.bucket(&bucket)
.key(&asset_key)
.send()
.await
.is_ok();

if source_exists {
client
.copy_object()
.copy_source(format!("{}/{}", &bucket, &asset_key))
.bucket(&bucket)
.key(&dest_key)
.send()
.await
.context("Failed to copy object")?;

assets.push(Asset {
name: asset.name.clone(),
key: generated_file_name,
});
}
}

duplicate.assets = assets;

let result = sqlx::query_scalar!(
"INSERT INTO projects (id, project) VALUES ($1, $2) RETURNING id",
Expand All @@ -452,12 +463,7 @@ pub async fn upload_asset(
mut multipart: Multipart,
) -> Result<Json<UploadResponse>> {
let bucket = std::env::var("PROJECTS_S3_BUCKET").unwrap();
let rand_string: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(40)
.map(char::from)
.collect();
let generated_file_name: String = format!("{}_{}.kml", Utc::now().timestamp(), rand_string);
let generated_file_name: String = generate_asset_name();
let temp_name = format!("assets/temp/{}", generated_file_name);
while let Some(field) = multipart.next_field().await.unwrap() {
if field.name() == Some("file") {
Expand Down Expand Up @@ -549,3 +555,12 @@ async fn delete_assets(client: Client, project_assets: &Vec<Asset>) {
}
}
}

fn generate_asset_name() -> String {
let rand_string: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(40)
.map(char::from)
.collect();
return format!("{}_{}.kml", Utc::now().timestamp(), rand_string);
}
2 changes: 1 addition & 1 deletion ui/locales/app.en.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@
"tbx_point_depth_label": "Depth (m BGL)",
"tbx_profile": "Topo Profile",
"tbx_profile_btn": "Show height profile",
"tbx_project_geometries": "Project Geometries",
"tbx_project_geometries": "Project",
"tbx_remove_btn_label": "Remove",
"tbx_request_aborted_warning": "Request aborted",
"tbx_save_editing_btn_label": "Save",
Expand Down
6 changes: 6 additions & 0 deletions ui/src/elements/dashboard/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {CreateProject, Project, Topic} from './ngm-dashboard';

export function isProject(projectOrTopic: Project | CreateProject | Topic | undefined): projectOrTopic is Project {
const project = <Project> projectOrTopic;
return !!project?.owner && !!project?.created;
}
5 changes: 3 additions & 2 deletions ui/src/elements/dashboard/ngm-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import AuthStore from '../../store/auth';
import '../hide-overflow';
import './ngm-project-edit';
import './ngm-project-topic-overview';
import {isProject} from './helpers';

type TextualAttribute = string | TranslatedText;

Expand Down Expand Up @@ -349,8 +350,8 @@ export class NgmDashboard extends LitElementI18n {
}

async onProjectSave(project: Project | CreateProject) {
if (this.projectMode === 'edit') {
await apiClient.updateProject(<Project>project);
if (this.projectMode === 'edit' && isProject(project)) {
await apiClient.updateProject(project);
this.projectMode = 'view';
} else if (this.projectMode === 'create' && this.projectToCreate) {
try {
Expand Down
8 changes: 5 additions & 3 deletions ui/src/elements/dashboard/ngm-project-edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {showSnackbarError} from '../../notifications';
import './ngm-project-geoms-section';
import './ngm-project-assets-section';
import {MemberToAdd} from './ngm-add-member-form';
import {isProject} from './helpers';

@customElement('ngm-project-edit')
export class NgmProjectEdit extends LitElementI18n {
Expand Down Expand Up @@ -110,9 +111,10 @@ export class NgmProjectEdit extends LitElementI18n {
</div>
</div>
</div>
<div class="ngm-proj-data" ?hidden="${this.createMode}">
${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString((<Project>project).modified)} ${i18next.t('dashboard_by_swisstopo_title')}`}
</div>
${this.createMode || !isProject(project) ? '' : html`
<div class="ngm-proj-data">
${`${i18next.t('dashboard_modified_title')} ${toLocaleDateString(project.modified)} ${i18next.t('dashboard_by_swisstopo_title')}`}
</div>`}
<div class="ngm-proj-information">
<div class="project-image-and-color">
<div class="ngm-proj-preview-img"
Expand Down
27 changes: 18 additions & 9 deletions ui/src/elements/dashboard/ngm-project-topic-overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import './ngm-project-geoms-section';
import './ngm-project-assets-section';
import './ngm-delete-warning-modal';
import './ngm-project-members-section';
import {isProject} from './helpers';

@customElement('ngm-project-topic-overview')
export class NgmProjectTopicOverview extends LitElementI18n {
Expand Down Expand Up @@ -41,8 +42,8 @@ export class NgmProjectTopicOverview extends LitElementI18n {

render() {
if (!this.topicOrProject) return '';
const ownerEmail = (<Project> this.topicOrProject).owner?.email;
const project: Project | undefined = ownerEmail ? <Project> this.topicOrProject : undefined;
const project = isProject(this.topicOrProject) ? this.topicOrProject : undefined;
const ownerEmail = project?.owner?.email;
const owner = ownerEmail || i18next.t('swisstopo');
const date = this.topicOrProject?.modified ? this.topicOrProject?.modified : this.topicOrProject?.created;
const backgroundImage = this.topicOrProject.image?.length ? `url('${this.topicOrProject.image}')` : 'none';
Expand Down Expand Up @@ -133,14 +134,13 @@ export class NgmProjectTopicOverview extends LitElementI18n {
${i18next.t('dashboard_share_topic')}
</div>
<div class="item ${apiClient.token ? '' : 'disabled'}"
?hidden=${this.activeTab !== 'topics'}
@click=${() => this.duplicateToProject()}>
${i18next.t('duplicate_to_project')}
</div>
<a class="item" target="_blank" href="mailto:?body=${encodeURIComponent(this.getLink() || '')}">
${i18next.t('dashboard_share_topic_email')}
</a>
${(<Project> this.topicOrProject)?.owner?.email !== this.userEmail ? '' : html`
${isProject(this.topicOrProject) && this.topicOrProject.owner.email !== this.userEmail ? '' : html`
<div class="item"
?hidden=${this.activeTab === 'topics'}
@click=${() => this.deleteWarningModal.show = true}>
Expand Down Expand Up @@ -181,12 +181,21 @@ export class NgmProjectTopicOverview extends LitElementI18n {
}

toCreateProject(topicOrProject: Topic | Project): CreateProject {
const title = isProject(topicOrProject) ?
`${i18next.t('tbx_copy_of_label')} ${topicOrProject.title}` :
translated(topicOrProject.title);
let description: string | undefined;
if (isProject(topicOrProject)) {
description = topicOrProject.description;
} else if (topicOrProject.description) {
description = translated(topicOrProject.description);
}
return {
color: DEFAULT_PROJECT_COLOR,
description: topicOrProject.description ? translated(topicOrProject.description) : undefined,
title: translated(topicOrProject.title),
geometries: topicOrProject.geometries, // not a copy
assets: topicOrProject.assets, // not a copy
title,
description,
color: isProject(topicOrProject) ? topicOrProject.color : DEFAULT_PROJECT_COLOR,
geometries: isProject(topicOrProject) ? topicOrProject.geometries : [], // not a copy for topic
assets: isProject(topicOrProject) ? topicOrProject.assets : [], // not a copy for topic
views: topicOrProject.views.map(view => ({
id: crypto.randomUUID(),
title: translated(view.title),
Expand Down
4 changes: 3 additions & 1 deletion ui/src/elements/view-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import $ from '../jquery';

import type {Project, Topic, View} from './dashboard/ngm-dashboard';
import {getPermalink} from '../permalink';
import {isProject} from './dashboard/helpers';

@customElement('view-menu')
export class ViewMenu extends LitElementI18n {
Expand Down Expand Up @@ -41,7 +42,8 @@ export class ViewMenu extends LitElementI18n {
}

async saveViewToProject() {
const project = <Project> this.selectedProject;
if (!isProject(this.selectedProject)) return;
const project = this.selectedProject;
if (this.viewIndex && this.userEmail && project.owner) {
if ([project.owner.email, ...project.editors].includes(this.userEmail)) {
const view: View = {
Expand Down
8 changes: 5 additions & 3 deletions ui/src/store/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {BehaviorSubject, Subject} from 'rxjs';
import type {Project, Topic} from '../elements/dashboard/ngm-dashboard';
import {NgmGeometry} from '../toolbox/interfaces';
import AuthStore from './auth';
import {isProject} from '../elements/dashboard/helpers';

export type TopicParam = { topicId: string, viewId?: string | null }

Expand Down Expand Up @@ -32,9 +33,10 @@ export default class DashboardStore {
static setViewIndex(value: number | undefined): void {
const projectOrTopic = this.selectedTopicOrProjectSubject.value;
if (value !== undefined && projectOrTopic) {
const owner = (<Project> projectOrTopic).owner?.email === AuthStore.userEmail;
const editor = !!(<Project> projectOrTopic).editors?.find(e => e.email === AuthStore.userEmail);
this.setProjectMode(owner || editor ? 'viewEdit' : 'viewOnly');
const owner = isProject(projectOrTopic) && projectOrTopic.owner.email === AuthStore.userEmail;
const editor = isProject(projectOrTopic) && !!projectOrTopic.editors?.find(e => e.email === AuthStore.userEmail);
const mode = owner || editor ? 'viewEdit' : 'viewOnly';
if (this.projectMode.value !== mode) this.setProjectMode(mode);
} else {
this.setProjectMode(undefined);
}
Expand Down
12 changes: 8 additions & 4 deletions ui/src/toolbox/ngm-geometries-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import $ from '../jquery.js';
import './ngm-geometries-simple-list';
import i18next from 'i18next';
import DashboardStore from '../store/dashboard';
import {Project} from '../elements/dashboard/ngm-dashboard';
import {isProject} from '../elements/dashboard/helpers';

@customElement('ngm-geometries-list')
export default class NgmGeometriesList extends LitElementI18n {
Expand Down Expand Up @@ -70,14 +70,16 @@ export default class NgmGeometriesList extends LitElementI18n {
render() {
const projectMode = DashboardStore.projectMode.value;
const projectEditMode = projectMode === 'viewEdit' || projectMode === 'edit';
const isProjectSelected = !!(<Project> DashboardStore.selectedTopicOrProject.value)?.owner;
const selectedProject = DashboardStore.selectedTopicOrProject.value;
return html`
<ngm-geometries-simple-list
.hidden=${!this.noEditGeometries?.length}
.geometries=${this.noEditGeometries}
.noEditMode=${true}
.selectedId=${this.selectedId}
.listTitle="${isProjectSelected ? i18next.t('tbx_project_geometries') : i18next.t('tbx_geometries_from_topic')}"
.listTitle="${isProject(selectedProject) ?
i18next.t('tbx_project_geometries') :
i18next.t('tbx_geometries_from_topic')}"
.optionsTemplate=${this.optionsTemplate}
.disabledTypes=${this.disabledTypes}
.disabledCallback=${this.disabledCallback}
Expand All @@ -87,7 +89,9 @@ export default class NgmGeometriesList extends LitElementI18n {
<ngm-geometries-simple-list
.geometries=${this.geometries}
.selectedId=${this.selectedId}
.listTitle="${projectEditMode ? i18next.t('tbx_project_geometries') : i18next.t('tbx_my_geometries')}"
.listTitle="${projectEditMode ?
`${selectedProject?.title} ${i18next.t('tbx_project_geometries')}` :
i18next.t('tbx_my_geometries')}"
.optionsTemplate=${this.optionsTemplate}
.disabledTypes=${this.disabledTypes}
.disabledCallback=${this.disabledCallback}
Expand Down
6 changes: 4 additions & 2 deletions ui/src/toolbox/ngm-toolbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,17 @@ export class NgmToolbox extends LitElementI18n {
DashboardStore.projectMode.pipe(pairwise()).subscribe(([previousMode, currentMode]) => {
if (!this.geometryController || !this.geometryControllerNoEdit) return;
const geometries = DashboardStore.selectedTopicOrProject.value?.geometries || [];
if (currentMode === 'edit' || currentMode === 'viewEdit') {
const currentModeEdit = currentMode === 'edit' || currentMode === 'viewEdit';
const prevModeEdit = previousMode === 'edit' || previousMode === 'viewEdit';
if (currentModeEdit) {
this.geometryController!.setGeometries(geometries);
this.geometryControllerNoEdit.setGeometries([]);
} else if (currentMode === 'viewOnly') {
this.geometryControllerNoEdit.setGeometries(geometries);
} else {
this.geometryControllerNoEdit.setGeometries([]);
}
if (previousMode === 'edit' || previousMode === 'viewEdit') {
if (!currentModeEdit && prevModeEdit) {
this.geometryController.setGeometries(LocalStorageController.getStoredAoi());
}
});
Expand Down

0 comments on commit 24cfea1

Please sign in to comment.