Browse Source

Rename "Current customer" source to "Current owner"

pull/14107/head
VIacheslavKlimov 8 months ago
parent
commit
56059ade08
  1. 34
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  2. 4
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  3. 200
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java
  4. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java
  5. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java
  6. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java
  7. 6
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CurrentOwnerDynamicSourceConfiguration.java
  8. 4
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java
  9. 6
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java

34
application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java

@ -19,13 +19,11 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import jakarta.annotation.Nullable;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy; import jakarta.annotation.PreDestroy;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
@ -47,7 +45,6 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -114,7 +111,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
if (!argument.hasOwnerSource()) { if (!argument.hasOwnerSource()) {
return entityId; return entityId;
} }
return resolveOwnerArgument(tenantId, entityId, argument); return resolveOwnerArgument(tenantId, entityId);
} }
protected Map<String, ArgumentEntry> resolveArgumentFutures(Map<String, ListenableFuture<ArgumentEntry>> argFutures) { protected Map<String, ArgumentEntry> resolveArgumentFutures(Map<String, ListenableFuture<ArgumentEntry>> argFutures) {
@ -166,14 +163,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
} }
var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration(); var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration();
return switch (refDynamicSourceConfiguration.getType()) { return switch (refDynamicSourceConfiguration.getType()) {
case CURRENT_CUSTOMER -> { case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId)));
EntityId resolved = resolveOwnerArgument(tenantId, entityId, value);
if (resolved != null) {
yield Futures.immediateFuture(List.of(resolved));
} else {
yield Futures.immediateFuture(Collections.emptyList());
}
}
case RELATION_QUERY -> { case RELATION_QUERY -> {
var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
if (configuration.isSimpleRelation()) { if (configuration.isSimpleRelation()) {
@ -192,21 +182,8 @@ public abstract class AbstractCalculatedFieldProcessingService {
}; };
} }
@Nullable private EntityId resolveOwnerArgument(TenantId tenantId, EntityId entityId) {
private EntityId resolveOwnerArgument(TenantId tenantId, EntityId entityId, Argument argument) { return ownerService.getOwner(tenantId, entityId);
return switch (argument.getRefDynamicSourceConfiguration().getType()) {
case CURRENT_CUSTOMER -> {
EntityId ownerId = ownerService.getOwner(tenantId, entityId);
if (ownerId.getEntityType() == EntityType.TENANT) {
// todo: if inherit is true - use customer id
// fixme: WTF do we need it at all?
yield null;
} else {
yield ownerId;
}
}
default -> throw new UnsupportedOperationException();
};
} }
private ListenableFuture<ArgumentEntry> fetchGeofencingKvEntry(TenantId tenantId, List<EntityId> geofencingEntities, Argument argument) { private ListenableFuture<ArgumentEntry> fetchGeofencingKvEntry(TenantId tenantId, List<EntityId> geofencingEntities, Argument argument) {
@ -234,9 +211,6 @@ public abstract class AbstractCalculatedFieldProcessingService {
} }
protected ListenableFuture<ArgumentEntry> fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) { protected ListenableFuture<ArgumentEntry> fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) {
if (entityId == null) {
return Futures.immediateFuture(transformSingleValueArgument(Optional.empty()));
}
return switch (argument.getRefEntityKey().getType()) { return switch (argument.getRefEntityKey().getType()) {
case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs); case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs);
case ATTRIBUTE -> fetchAttribute(tenantId, entityId, argument, startTs); case ATTRIBUTE -> fetchAttribute(tenantId, entityId, argument, startTs);

4
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -41,7 +41,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CurrentCustomerDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent;
@ -218,7 +218,7 @@ public class AlarmRulesTest extends AbstractControllerTest {
Argument temperatureThresholdArgument = new Argument(); Argument temperatureThresholdArgument = new Argument();
temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentCustomerDynamicSourceConfiguration()); temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
temperatureThresholdArgument.setDefaultValue("1000"); temperatureThresholdArgument.setDefaultValue("1000");
Map<String, Argument> arguments = Map.of( Map<String, Argument> arguments = Map.of(

200
application/src/test/java/org/thingsboard/server/cf/CalculatedFieldCurrentOwnerTest.java

@ -0,0 +1,200 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.cf;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.controller.AbstractControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
public class CalculatedFieldCurrentOwnerTest extends AbstractControllerTest {
public static final int TIMEOUT = 60;
public static final int POLL_INTERVAL = 1;
@Test
public void testCreateCFWithCurrentOwner() throws Exception {
loginTenantAdmin();
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}");
Device testDevice = createDevice("Test device", "1234567890");
doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class);
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105");
});
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":10}");
await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("110");
});
}
@Test
public void testChangeOwner() throws Exception {
loginSysAdmin();
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}");
loginTenantAdmin();
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}");
Device testDevice = createDevice("Test device", "1234567890");
doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class);
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105");
});
doDelete("/api/customer/device/" + testDevice.getId().getId()).andExpect(status().isOk());
await().alias("change owner -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result");
assertThat(fahrenheitTemp).isNotNull();
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("150");
});
}
@Test
public void testCreateCFWithCurrentOwnerWhenEntityIsProfile() throws Exception {
loginSysAdmin();
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}");
loginTenantAdmin();
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}");
AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class);
Asset asset1 = createAsset("Test asset 1", assetProfile.getId());
doPost("/api/customer/" + customerId.getId() + "/asset/" + asset1.getId().getId()).andExpect(status().isOk());
Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); // owner - TENANT
CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(assetProfile.getId()), CalculatedField.class);
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
// result of asset 1
ObjectNode result1 = getLatestTelemetry(asset1.getId(), "result");
assertThat(result1).isNotNull();
assertThat(result1.get("result").get(0).get("value").asText()).isEqualTo("105");
// result of asset 2
ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result");
assertThat(result2).isNotNull();
assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("150");
});
doPost("/api/customer/" + customerId.getId() + "/asset/" + asset2.getId().getId()).andExpect(status().isOk());
await().alias("change asset2 owner -> recalculate state for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
// result of asset 2
ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result");
assertThat(result2).isNotNull();
assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("105");
});
}
private CalculatedField buildCalculatedField(EntityId entityId) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(entityId);
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setName("Test Calculated Field");
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument argument = new Argument();
ReferencedEntityKey refEntityKey = new ReferencedEntityKey("attrKey", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE);
argument.setRefEntityKey(refEntityKey);
argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
config.setArguments(Map.of("a", argument));
config.setExpression("a + 100");
Output output = new Output();
output.setName("result");
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
config.setOutput(output);
calculatedField.setConfiguration(config);
return calculatedField;
}
private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
}
private Asset createAsset(String name, AssetProfileId assetProfileId) {
Asset asset = new Asset();
asset.setName(name);
asset.setAssetProfileId(assetProfileId);
return doPost("/api/asset", asset, Asset.class);
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java

@ -42,7 +42,7 @@ public class Argument {
} }
public boolean hasOwnerSource() { public boolean hasOwnerSource() {
return hasDynamicSource() && refDynamicSourceConfiguration.getType() == CFArgumentDynamicSourceType.CURRENT_CUSTOMER; return hasDynamicSource() && refDynamicSourceConfiguration.getType() == CFArgumentDynamicSourceType.CURRENT_OWNER;
} }
} }

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java

@ -17,7 +17,7 @@ package org.thingsboard.server.common.data.cf.configuration;
public enum CFArgumentDynamicSourceType { public enum CFArgumentDynamicSourceType {
CURRENT_CUSTOMER, CURRENT_OWNER,
RELATION_QUERY RELATION_QUERY
} }

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java

@ -27,7 +27,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
) )
@JsonSubTypes({ @JsonSubTypes({
@JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY"), @JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY"),
@JsonSubTypes.Type(value = CurrentCustomerDynamicSourceConfiguration.class, name = "CURRENT_CUSTOMER") @JsonSubTypes.Type(value = CurrentOwnerDynamicSourceConfiguration.class, name = "CURRENT_OWNER")
}) })
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public interface CfArgumentDynamicSourceConfiguration { public interface CfArgumentDynamicSourceConfiguration {

6
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CurrentCustomerDynamicSourceConfiguration.java → common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CurrentOwnerDynamicSourceConfiguration.java

@ -18,13 +18,11 @@ package org.thingsboard.server.common.data.cf.configuration;
import lombok.Data; import lombok.Data;
@Data @Data
public class CurrentCustomerDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { public class CurrentOwnerDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration {
private boolean inherit; // TODO: implement
@Override @Override
public CFArgumentDynamicSourceType getType() { public CFArgumentDynamicSourceType getType() {
return CFArgumentDynamicSourceType.CURRENT_CUSTOMER; return CFArgumentDynamicSourceType.CURRENT_OWNER;
} }
} }

4
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java

@ -38,9 +38,9 @@ public class ArgumentTest {
} }
@Test @Test
void validateWhenCurrentCustomerSourceConfigurationIsNotNull() { void validateWhenCurrentOwnerSourceConfigurationIsNotNull() {
var argument = new Argument(); var argument = new Argument();
argument.setRefDynamicSourceConfiguration(new CurrentCustomerDynamicSourceConfiguration()); argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
assertThat(argument.hasDynamicSource()).isTrue(); assertThat(argument.hasDynamicSource()).isTrue();
assertThat(argument.hasOwnerSource()).isTrue(); assertThat(argument.hasOwnerSource()).isTrue();
assertThat(argument.hasRelationQuerySource()).isFalse(); assertThat(argument.hasRelationQuerySource()).isFalse();

6
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java

@ -22,7 +22,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CurrentCustomerDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.CurrentOwnerDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntityRelation;
@ -113,9 +113,9 @@ public class ZoneGroupConfigurationTest {
} }
@Test @Test
void whenHasRelationQuerySourceCalled_shouldReturnFalseIfCurrentCustomerSourceConfigured() { void whenHasRelationQuerySourceCalled_shouldReturnFalseIfCurrentOwnerSourceConfigured() {
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(new CurrentCustomerDynamicSourceConfiguration()); zoneGroupConfiguration.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
assertThat(zoneGroupConfiguration.hasRelationQuerySource()).isFalse(); assertThat(zoneGroupConfiguration.hasRelationQuerySource()).isFalse();
} }

Loading…
Cancel
Save