Browse Source

SONAR-10511 Project key renaming should rename deleted components too

tags/7.5
Simon Brandhof 6 years ago
parent
commit
492942631b

+ 6
- 6
server/sonar-db-dao/src/main/java/org/sonar/db/component/ComponentKeyUpdaterDao.java View File

@@ -83,7 +83,7 @@ public class ComponentKeyUpdaterDao implements Dao {
* @return a map with currentKey/newKey is a bulk update was executed
*/
public Map<String, String> simulateBulkUpdateKey(DbSession dbSession, String projectUuid, String stringToReplace, String replacementString) {
return collectAllModules(projectUuid, stringToReplace, mapper(dbSession))
return collectAllModules(projectUuid, stringToReplace, mapper(dbSession), false)
.stream()
.collect(Collectors.toMap(
ResourceDto::getKey,
@@ -109,7 +109,7 @@ public class ComponentKeyUpdaterDao implements Dao {
public void bulkUpdateKey(DbSession session, String projectUuid, String stringToReplace, String replacementString) {
ComponentKeyUpdaterMapper mapper = session.getMapper(ComponentKeyUpdaterMapper.class);
// must SELECT first everything
Set<ResourceDto> modules = collectAllModules(projectUuid, stringToReplace, mapper);
Set<ResourceDto> modules = collectAllModules(projectUuid, stringToReplace, mapper, true);
checkNewNameOfAllModules(modules, stringToReplace, replacementString, mapper);

// add branches (no check should be done as branch keys cannot be changed by the user)
@@ -118,7 +118,7 @@ public class ComponentKeyUpdaterDao implements Dao {
.stream()
.filter(branch -> !projectUuid.equals(branch.getUuid()))
.forEach(branch -> {
Set<ResourceDto> branchModules = collectAllModules(branch.getUuid(), stringToReplace, mapper);
Set<ResourceDto> branchModules = collectAllModules(branch.getUuid(), stringToReplace, mapper, true);
modules.addAll(branchModules);
branchModules.forEach(module -> branchBaseKeys.put(module.getKey(), branchBaseKey(module.getKey())));
});
@@ -165,14 +165,14 @@ public class ComponentKeyUpdaterDao implements Dao {
}
}

private static Set<ResourceDto> collectAllModules(String projectUuid, String stringToReplace, ComponentKeyUpdaterMapper mapper) {
private static Set<ResourceDto> collectAllModules(String projectUuid, String stringToReplace, ComponentKeyUpdaterMapper mapper, boolean includeDisabled) {
ResourceDto project = mapper.selectProject(projectUuid);
Set<ResourceDto> modules = new HashSet<>();
if (project.getKey().contains(stringToReplace)) {
if (project.getKey().contains(stringToReplace) && (project.isEnabled() || includeDisabled)) {
modules.add(project);
}
for (ResourceDto submodule : mapper.selectDescendantProjects(projectUuid)) {
modules.addAll(collectAllModules(submodule.getUuid(), stringToReplace, mapper));
modules.addAll(collectAllModules(submodule.getUuid(), stringToReplace, mapper, includeDisabled));
}
return modules;
}

+ 0
- 2
server/sonar-db-dao/src/main/resources/org/sonar/db/component/ComponentKeyUpdaterMapper.xml View File

@@ -29,7 +29,6 @@
where
root_uuid = #{rootUuid,jdbcType=VARCHAR}
and scope != 'PRJ'
and enabled = ${_true}
</select>

<select id="selectDescendantProjects" parameterType="String" resultMap="resourceResultMap">
@@ -38,7 +37,6 @@
scope='PRJ'
and root_uuid = #{rootUuid,jdbcType=VARCHAR}
and uuid != #{rootUuid,jdbcType=VARCHAR}
and enabled = ${_true}
</select>

<update id="update" parameterType="Resource">

+ 15
- 10
server/sonar-db-dao/src/test/java/org/sonar/db/component/ComponentKeyUpdaterDaoTest.java View File

@@ -63,7 +63,7 @@ public class ComponentKeyUpdaterDaoTest {
}

@Test
public void updateKey_does_not_update_inactive_components() {
public void updateKey_updates_disabled_components() {
OrganizationDto organizationDto = db.organizations().insert();
ComponentDto project = db.components().insertComponent(newPrivateProjectDto(organizationDto, "A").setDbKey("my_project"));
ComponentDto directory = db.components().insertComponent(newDirectory(project, "/directory").setDbKey("my_project:directory"));
@@ -75,8 +75,10 @@ public class ComponentKeyUpdaterDaoTest {
dbSession.commit();

List<ComponentDto> result = dbClient.componentDao().selectAllComponentsFromProjectKey(dbSession, "your_project");
assertThat(result).hasSize(5).extracting(ComponentDto::getDbKey)
.containsOnlyOnce("your_project", "your_project:directory", "your_project:directory/file", "my_project:inactive_directory", "my_project:inactive_directory/file");
assertThat(result)
.hasSize(5)
.extracting(ComponentDto::getDbKey)
.containsOnlyOnce("your_project", "your_project:directory", "your_project:directory/file", "your_project:inactive_directory", "your_project:inactive_directory/file");
}

@Test
@@ -216,7 +218,7 @@ public class ComponentKeyUpdaterDaoTest {
}

@Test
public void bulk_update_key_does_not_update_inactive_components() {
public void bulk_update_key_updates_disabled_components() {
ComponentDto project = db.components().insertComponent(newPrivateProjectDto(db.getDefaultOrganization(), "A").setDbKey("my_project"));
db.components().insertComponent(newModuleDto(project).setDbKey("my_project:module"));
db.components().insertComponent(newModuleDto(project).setDbKey("my_project:inactive_module").setEnabled(false));
@@ -224,8 +226,10 @@ public class ComponentKeyUpdaterDaoTest {
underTest.bulkUpdateKey(dbSession, "A", "my_", "your_");

List<ComponentDto> result = dbClient.componentDao().selectAllComponentsFromProjectKey(dbSession, "your_project");
assertThat(result).hasSize(3).extracting(ComponentDto::getDbKey)
.containsOnlyOnce("your_project", "your_project:module", "my_project:inactive_module");
assertThat(result)
.hasSize(3)
.extracting(ComponentDto::getDbKey)
.containsOnlyOnce("your_project", "your_project:module", "your_project:inactive_module");
}

@Test
@@ -328,16 +332,17 @@ public class ComponentKeyUpdaterDaoTest {
}

@Test
public void simulate_bulk_update_key_do_not_return_disable_components() {
public void simulate_bulk_update_key_does_not_return_disable_components() {
ComponentDto project = db.components().insertComponent(newPrivateProjectDto(db.getDefaultOrganization(), "A").setDbKey("project"));
db.components().insertComponent(newModuleDto(project).setDbKey("project:enabled-module"));
db.components().insertComponent(newModuleDto(project).setDbKey("project:disabled-module").setEnabled(false));
db.components().insertComponent(newPrivateProjectDto(db.getDefaultOrganization(), "D").setDbKey("other-project"));

Map<String, String> result = underTest.simulateBulkUpdateKey(dbSession, "A", "project", "new-project");

assertThat(result)
.hasSize(2)
.containsOnly(entry("project", "new-project"), entry("project:enabled-module", "new-project:enabled-module"));
assertThat(result).containsOnly(
entry("project", "new-project"),
entry("project:enabled-module", "new-project:enabled-module"));
}

@Test

+ 15
- 0
server/sonar-qa-util/src/main/java/org/sonarqube/qa/util/ProjectTester.java View File

@@ -22,8 +22,11 @@ package org.sonarqube.qa.util;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import javax.annotation.Nullable;
import org.sonarqube.ws.Components;
import org.sonarqube.ws.Organizations;
import org.sonarqube.ws.Projects;
import org.sonarqube.ws.client.HttpException;
import org.sonarqube.ws.client.components.ShowRequest;
import org.sonarqube.ws.client.projects.CreateRequest;
import org.sonarqube.ws.client.projects.DeleteRequest;
import org.sonarqube.ws.client.projects.ProjectsService;
@@ -64,4 +67,16 @@ public class ProjectTester {

return session.wsClient().projects().create(request).getProject();
}

public boolean exists(String projectKey) {
try {
Components.ShowWsResponse response = session.wsClient().components().show(new ShowRequest().setComponent(projectKey));
return response.getComponent() != null;
} catch (HttpException e) {
if (e.code() == 404) {
return false;
}
throw new IllegalStateException(e);
}
}
}

+ 2
- 2
server/sonar-server/src/test/java/org/sonar/server/component/ComponentServiceTest.java View File

@@ -65,8 +65,8 @@ public class ComponentServiceTest {
assertComponentKeyUpdated(project.getDbKey(), "your_project");
assertComponentKeyUpdated(module.getDbKey(), "your_project:root:module");
assertComponentKeyUpdated(file.getDbKey(), "your_project:root:module:src/File.xoo");
assertComponentKeyNotUpdated(inactiveModule.getDbKey());
assertComponentKeyNotUpdated(inactiveFile.getDbKey());
assertComponentKeyUpdated(inactiveModule.getDbKey(), "your_project:root:inactive_module");
assertComponentKeyUpdated(inactiveFile.getDbKey(), "your_project:root:module:src/InactiveFile.xoo");
}

private void assertComponentKeyUpdated(String oldKey, String newKey) {

+ 4
- 7
server/sonar-server/src/test/java/org/sonar/server/component/ComponentServiceUpdateKeyTest.java View File

@@ -76,8 +76,9 @@ public class ComponentServiceUpdateKeyTest {
// Check file key has been updated
assertThat(db.getDbClient().componentDao().selectByKey(dbSession, file.getDbKey())).isAbsent();
assertThat(db.getDbClient().componentDao().selectByKey(dbSession, "sample2:root:src/File.xoo")).isNotNull();
assertThat(db.getDbClient().componentDao().selectByKey(dbSession, "sample2:root:src/InactiveFile.xoo")).isNotNull();

assertThat(dbClient.componentDao().selectByKey(dbSession, inactiveFile.getDbKey())).isPresent();
assertThat(dbClient.componentDao().selectByKey(dbSession, inactiveFile.getDbKey())).isAbsent();

org.assertj.core.api.Assertions.assertThat(projectIndexers.hasBeenCalled(project.uuid(), ProjectIndexer.Cause.PROJECT_KEY_UPDATE)).isTrue();
}
@@ -185,8 +186,8 @@ public class ComponentServiceUpdateKeyTest {
assertComponentKeyUpdated(project.getDbKey(), "your_project");
assertComponentKeyUpdated(module.getDbKey(), "your_project:root:module");
assertComponentKeyUpdated(file.getDbKey(), "your_project:root:module:src/File.xoo");
assertComponentKeyNotUpdated(inactiveModule.getDbKey());
assertComponentKeyNotUpdated(inactiveFile.getDbKey());
assertComponentKeyUpdated(inactiveModule.getDbKey(), "your_project:root:inactive_module");
assertComponentKeyUpdated(inactiveFile.getDbKey(), "your_project:root:module:src/InactiveFile.xoo");
}

private void assertComponentKeyUpdated(String oldKey, String newKey) {
@@ -194,10 +195,6 @@ public class ComponentServiceUpdateKeyTest {
assertThat(dbClient.componentDao().selectByKey(dbSession, newKey)).isPresent();
}

private void assertComponentKeyNotUpdated(String key) {
assertThat(dbClient.componentDao().selectByKey(dbSession, key)).isPresent();
}

private ComponentDto insertSampleRootProject() {
return insertProject("sample:root");
}

+ 217
- 24
tests/src/test/java/org/sonarqube/tests/project/ProjectKeyUpdateTest.java View File

@@ -21,31 +21,43 @@ package org.sonarqube.tests.project;

import com.sonar.orchestrator.Orchestrator;
import com.sonar.orchestrator.build.SonarScanner;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Collectors;
import javax.annotation.CheckForNull;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang.StringUtils;
import org.junit.After;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.DisableOnDebug;
import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestRule;
import org.junit.rules.Timeout;
import org.sonarqube.qa.util.Tester;
import org.sonarqube.ws.Components;
import org.sonarqube.ws.Organizations;
import org.sonarqube.ws.Projects;
import org.sonarqube.ws.Projects.BulkUpdateKeyWsResponse.Key;
import org.sonarqube.ws.client.GetRequest;
import org.sonarqube.ws.client.WsResponse;
import org.sonarqube.ws.client.components.SearchProjectsRequest;
import org.sonarqube.ws.client.components.ShowRequest;
import org.sonarqube.ws.client.components.TreeRequest;
import org.sonarqube.ws.client.projects.BulkUpdateKeyRequest;
import org.sonarqube.ws.client.projects.UpdateKeyRequest;
import org.sonarqube.ws.client.projects.CreateRequest;
import util.ItUtils;

import static java.util.Arrays.asList;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.groups.Tuple.tuple;
import static util.ItUtils.projectDir;

public class ProjectKeyUpdateTest {
@@ -57,7 +69,8 @@ public class ProjectKeyUpdateTest {

@Rule
public TestRule safeguard = new DisableOnDebug(Timeout.seconds(300));

@Rule
public TemporaryFolder temp = new TemporaryFolder();
@Rule
public Tester tester = new Tester(orchestrator).setElasticsearchHttpPort(ProjectSuite.SEARCH_HTTP_PORT);

@@ -67,17 +80,18 @@ public class ProjectKeyUpdateTest {
}

@Test
public void update_key() {
analyzeXooSample();
String newProjectKey = "another_project_key";
Components.Component project = tester.wsClient().components().show(new ShowRequest().setComponent(PROJECT_KEY)).getComponent();
assertThat(project.getKey()).isEqualTo(PROJECT_KEY);
public void update_key() throws IOException {
Organizations.Organization organization = tester.organizations().generate();
File projectDir = new XooProjectBuilder(PROJECT_KEY)
.build(temp.newFolder());
analyze(organization, projectDir);
assertThat(tester.projects().exists(PROJECT_KEY)).isTrue();

tester.wsClient().projects().updateKey(new UpdateKeyRequest()
.setFrom(PROJECT_KEY)
.setTo(newProjectKey));
String newProjectKey = "renamed";
updateProjectKey(PROJECT_KEY, newProjectKey, false);

assertThat(tester.wsClient().components().show(new ShowRequest().setComponentId(project.getId())).getComponent().getKey()).isEqualTo(newProjectKey);
assertThat(tester.projects().exists(PROJECT_KEY)).isFalse();
assertThat(tester.projects().exists(newProjectKey)).isTrue();
}

@Test
@@ -85,7 +99,7 @@ public class ProjectKeyUpdateTest {
Organizations.Organization organization = tester.organizations().generate();
Projects.CreateWsResponse.Project project = createProject(organization, "one", "Foo");

updateKey(project, "two");
updateProjectKey(project.getKey(), "two", false);

assertThat(isProjectInDatabase("one")).isFalse();
assertThat(isProjectInDatabase("two")).isTrue();
@@ -102,7 +116,7 @@ public class ProjectKeyUpdateTest {

lockWritesOnProjectIndices();

updateKey(project, "two");
updateProjectKey(project.getKey(), "two", false);

assertThat(isProjectInDatabase("one")).isFalse();

@@ -142,7 +156,7 @@ public class ProjectKeyUpdateTest {
String initialKey = "com.sonarsource.it.samples:multi-modules-sample:module_a";
String newKey = "com.sonarsource.it.samples:multi-modules-sample:module_c";

updateKey(initialKey, newKey);
updateModuleKey(initialKey, newKey);

assertThat(isComponentInDatabase(initialKey)).isFalse();
assertThat(isComponentInDatabase(newKey)).isTrue();
@@ -168,7 +182,7 @@ public class ProjectKeyUpdateTest {
String newKey = "com.sonarsource.it.samples:multi-modules-sample:module_c";

lockWritesOnProjectIndices();
updateKey(initialKey, newKey);
updateModuleKey(initialKey, newKey);

// api/components/search loads keys from db, so results are consistent
assertThat(isComponentInDatabase(initialKey)).isFalse();
@@ -193,7 +207,133 @@ public class ProjectKeyUpdateTest {
Thread.sleep(1_000L);
recovered = keysInComponentSuggestions(newKey).contains(newKey) && keysInComponentSuggestions(initialKey).isEmpty();
}
}

/**
* SONAR-10511
*/
@Test
public void update_key_of_disabled_files() throws Exception {
Organizations.Organization organization = tester.organizations().generate();

// first analysis
File projectWith2Files = new XooProjectBuilder(PROJECT_KEY)
.setFilesPerModule(2)
.build(temp.newFolder());
analyze(organization, projectWith2Files);
assertThat(countFilesInProject()).isEqualTo(2);

// second analysis emulates a deletion of file
File projectWith1File = new XooProjectBuilder(PROJECT_KEY)
.setFilesPerModule(1)
.build(temp.newFolder());
analyze(organization, projectWith1File);
assertThat(countFilesInProject()).isEqualTo(1);

// update the project key
updateProjectKey(PROJECT_KEY, "renamed", false);
ItUtils.expectNotFoundError(() -> tester.wsClient().components().show(new ShowRequest().setComponent(PROJECT_KEY)));

// first analysis of the new project, which re-enables the deleted file
analyze(organization, projectWith2Files);
assertThat(countFilesInProject()).isEqualTo(2);
}

/**
* SONAR-10511
*/
@Test
public void update_of_project_key_includes_disabled_modules() throws Exception {
Organizations.Organization organization = tester.organizations().generate();

// first analysis
File projectWithModulesAB = new XooProjectBuilder(PROJECT_KEY)
.addModules("module_a", "module_b")
.build(temp.newFolder());
analyze(organization, projectWithModulesAB);
assertThat(countFilesInProject()).isEqualTo(3);

// second analysis emulates deletion of module_b
File projectWithModuleA = new XooProjectBuilder(PROJECT_KEY)
.addModules("module_a")
.build(temp.newFolder());
analyze(organization, projectWithModuleA);
assertThat(countFilesInProject()).isEqualTo(2);

// update the project key
updateProjectKey(PROJECT_KEY, "renamed", false);
assertThat(tester.projects().exists(PROJECT_KEY)).isFalse();

// analysis of new project, re-enabling the deleted module
File projectWithModulesBC = new XooProjectBuilder(PROJECT_KEY)
.addModules("module_b", "module_c")
.build(temp.newFolder());
analyze(organization, projectWithModulesBC);
assertThat(countFilesInProject()).isEqualTo(3);
}

@Test
public void simulate_update_key_of_modules() throws Exception {
Organizations.Organization organization = tester.organizations().generate();

File project = new XooProjectBuilder(PROJECT_KEY)
.addModules("module_a", "module_b")
.build(temp.newFolder());
analyze(organization, project);
assertThat(tester.projects().exists(PROJECT_KEY)).isTrue();

// simulate update of project key
Projects.BulkUpdateKeyWsResponse response = updateProjectKey(PROJECT_KEY, "renamed", true);

assertThat(tester.projects().exists(PROJECT_KEY)).isTrue();
assertThat(tester.projects().exists("renamed")).isFalse();
assertThat(response.getKeysList())
.extracting(Key::getKey, Key::getNewKey)
.containsExactlyInAnyOrder(
tuple(PROJECT_KEY, "renamed"),
tuple(PROJECT_KEY + ":module_a", "renamed:module_a"),
tuple(PROJECT_KEY + ":module_b", "renamed:module_b"));
}

@Test
public void simulate_update_key_of_disabled_modules() throws Exception {
Organizations.Organization organization = tester.organizations().generate();

// first analysis
File projectWithModulesAB = new XooProjectBuilder(PROJECT_KEY)
.addModules("module_a", "module_b")
.build(temp.newFolder());
analyze(organization, projectWithModulesAB);
assertThat(countFilesInProject()).isEqualTo(3);

// second analysis emulates deletion of module_b
File projectWithModuleA = new XooProjectBuilder(PROJECT_KEY)
.addModules("module_a")
.build(temp.newFolder());
analyze(organization, projectWithModuleA);
assertThat(countFilesInProject()).isEqualTo(2);

// update the project key
Projects.BulkUpdateKeyWsResponse response = updateProjectKey(PROJECT_KEY, "renamed", true);

assertThat(tester.projects().exists(PROJECT_KEY)).isTrue();
assertThat(tester.projects().exists("renamed")).isFalse();
assertThat(response.getKeysList())
.extracting(Key::getKey, Key::getNewKey)
.containsExactlyInAnyOrder(
tuple(PROJECT_KEY, "renamed"),
tuple(PROJECT_KEY + ":module_a", "renamed:module_a"));
}

private int countFilesInProject() {
TreeRequest request = new TreeRequest().setComponent(PROJECT_KEY).setQualifiers(asList("FIL"));
return tester.wsClient().components().tree(request).getComponentsCount();
}

private void analyze(Organizations.Organization organization, File projectDir) {
orchestrator.executeBuild(SonarScanner.create(projectDir,
"sonar.organization", organization.getKey(),
"sonar.login", "admin", "sonar.password", "admin"));
}

private void lockWritesOnProjectIndices() throws Exception {
@@ -206,17 +346,20 @@ public class ProjectKeyUpdateTest {
tester.elasticsearch().unlockWrites("projectmeasures");
}

private void updateKey(Projects.CreateWsResponse.Project project, String newKey) {
tester.wsClient().projects().updateKey(new UpdateKeyRequest().setFrom(project.getKey()).setTo(newKey));
private void updateModuleKey(String initialKey, String newKey) {
tester.wsClient().projects().updateKey(new UpdateKeyRequest().setFrom(initialKey).setTo(newKey));
}

private void updateKey(String initialKey, String newKey) {
tester.wsClient().projects().updateKey(new UpdateKeyRequest().setFrom(initialKey).setTo(newKey));
private Projects.BulkUpdateKeyWsResponse updateProjectKey(String initialKey, String newKey, boolean dryRun) {
return tester.wsClient().projects().bulkUpdateKey(new BulkUpdateKeyRequest()
.setProject(initialKey)
.setFrom(initialKey)
.setTo(newKey)
.setDryRun(String.valueOf(dryRun)));
}

private Projects.CreateWsResponse.Project createProject(Organizations.Organization organization, String key, String name) {
CreateRequest createRequest = new CreateRequest().setProject(key).setName(name).setOrganization(organization.getKey());
return tester.wsClient().projects().create(createRequest).getProject();
return tester.projects().provision(organization, r -> r.setProject(key).setName(name));
}

private boolean isProjectInDatabase(String projectKey) {
@@ -255,8 +398,58 @@ public class ProjectKeyUpdateTest {
.collect(Collectors.toList());
}

private void analyzeXooSample() {
SonarScanner build = SonarScanner.create(projectDir("shared/xoo-sample"));
orchestrator.executeBuild(build);
private static class XooProjectBuilder {
private final String key;
private final List<String> moduleKeys = new ArrayList<>();
private int filesPerModule = 1;

XooProjectBuilder(String projectKey) {
this.key = projectKey;
}

XooProjectBuilder addModules(String key, String... otherKeys) {
this.moduleKeys.add(key);
this.moduleKeys.addAll(asList(otherKeys));
return this;
}

XooProjectBuilder setFilesPerModule(int i) {
this.filesPerModule = i;
return this;
}

File build(File dir) {
for (String moduleKey : moduleKeys) {
generateModule(moduleKey, new File(dir, moduleKey), new Properties());
}
Properties additionalProps = new Properties();
additionalProps.setProperty("sonar.modules", StringUtils.join(moduleKeys, ","));
generateModule(key, dir, additionalProps);
return dir;
}

private void generateModule(String key, File dir, Properties additionalProps) {
try {
File sourceDir = new File(dir, "src");
FileUtils.forceMkdir(sourceDir);
for (int i = 0; i < filesPerModule; i++) {
File sourceFile = new File(sourceDir, "File" + i + ".xoo");
FileUtils.write(sourceFile, "content of " + sourceFile.getName());
}
Properties props = new Properties();
props.setProperty("sonar.projectKey", key);
props.setProperty("sonar.projectName", key);
props.setProperty("sonar.projectVersion", "1.0");
props.setProperty("sonar.sources", sourceDir.getName());
props.putAll(additionalProps);
File propsFile = new File(dir, "sonar-project.properties");
try (OutputStream output = FileUtils.openOutputStream(propsFile)) {
props.store(output, "generated");
}
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}

}

Loading…
Cancel
Save