Browse Source

Basic data validation & fixed calculated field update in scheduled update msg

pull/13920/head
dshvaika 1 year ago
parent
commit
4cd0ec9e27
  1. 11
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  2. 4
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldScheduledCheckForUpdatesMsg.java
  3. 8
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  4. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java
  5. 16
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
  6. 11
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  7. 64
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java
  8. 13
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java
  9. 4
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  10. 12
      dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java
  11. 2
      dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java
  12. 21
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java

11
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java

@ -544,7 +544,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
return; return;
} }
long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(cfConfig.getScheduledUpdateIntervalSec()); long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(cfConfig.getScheduledUpdateIntervalSec());
var scheduledMsg = new CalculatedFieldScheduledCheckForUpdatesMsg(tenantId, cfCtx); var scheduledMsg = new CalculatedFieldScheduledCheckForUpdatesMsg(tenantId, cfCtx.getCfId());
ScheduledFuture<?> scheduledFuture = systemContext ScheduledFuture<?> scheduledFuture = systemContext
.schedulePeriodicMsgWithDelay(ctx, scheduledMsg, refreshDynamicSourceInterval, refreshDynamicSourceInterval); .schedulePeriodicMsgWithDelay(ctx, scheduledMsg, refreshDynamicSourceInterval, refreshDynamicSourceInterval);
@ -553,9 +553,14 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} }
public void onScheduledCheckForUpdatesMsg(CalculatedFieldScheduledCheckForUpdatesMsg msg) { public void onScheduledCheckForUpdatesMsg(CalculatedFieldScheduledCheckForUpdatesMsg msg) {
CalculatedFieldCtx cfCtx = msg.getCfCtx(); log.debug("[{}] [{}] Processing CF scheduled update msg.", tenantId, msg.getCfId());
CalculatedFieldCtx cfCtx = calculatedFields.get(msg.getCfId());
if (cfCtx == null) {
log.debug("[{}][{}] Failed to find CF context, going to stop scheduler updates.", tenantId, msg.getCfId());
cancelCfUpdateTaskIfExists(msg.getCfId(), true);
return;
}
EntityId entityId = cfCtx.getEntityId(); EntityId entityId = cfCtx.getEntityId();
log.debug("[{}] [{}] Processing CF scheduled update msg.", cfCtx.getCfId(), entityId);
EntityType entityType = entityId.getEntityType(); EntityType entityType = entityId.getEntityType();
if (isProfileEntity(entityType)) { if (isProfileEntity(entityType)) {
var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId); var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId);

4
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldScheduledCheckForUpdatesMsg.java

@ -16,16 +16,16 @@
package org.thingsboard.server.actors.calculatedField; package org.thingsboard.server.actors.calculatedField;
import lombok.Data; import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
@Data @Data
public class CalculatedFieldScheduledCheckForUpdatesMsg implements ToCalculatedFieldSystemMsg { public class CalculatedFieldScheduledCheckForUpdatesMsg implements ToCalculatedFieldSystemMsg {
private final TenantId tenantId; private final TenantId tenantId;
private final CalculatedFieldCtx cfCtx; private final CalculatedFieldId cfId;
@Override @Override
public MsgType getMsgType() { public MsgType getMsgType() {

8
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java

@ -93,10 +93,10 @@ import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.DataConstants.SCOPE;
import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.ENTITY_ID_LATITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.RESTRICTED_ZONES_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.RESTRICTED_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.SAVE_ZONES_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.SAVE_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent @TbRuleEngineComponent

12
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java

@ -31,14 +31,14 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.RESTRICTED_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.SAVE_ZONES_ARGUMENT_KEY;
@Data @Data
public class GeofencingCalculatedFieldState implements CalculatedFieldState { public class GeofencingCalculatedFieldState implements CalculatedFieldState {
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
public static final String SAVE_ZONES_ARGUMENT_KEY = "saveZones";
public static final String RESTRICTED_ZONES_ARGUMENT_KEY = "restrictedZones";
private List<String> requiredArguments; private List<String> requiredArguments;
private Map<String, ArgumentEntry> arguments; private Map<String, ArgumentEntry> arguments;
@ -73,7 +73,7 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
String key = entry.getKey(); String key = entry.getKey();
ArgumentEntry newEntry = entry.getValue(); ArgumentEntry newEntry = entry.getValue();
checkArgumentSize(key, newEntry, ctx); checkArgumentSize(key, newEntry, ctx);
ArgumentEntry existingEntry = arguments.get(key); ArgumentEntry existingEntry = arguments.get(key);
boolean entryUpdated; boolean entryUpdated;

16
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java

@ -33,6 +33,8 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel
protected String expression; protected String expression;
protected Output output; protected Output output;
protected int scheduledUpdateIntervalSec;
@Override @Override
public List<EntityId> getReferencedEntities() { public List<EntityId> getReferencedEntities() {
return arguments.values().stream() return arguments.values().stream()
@ -58,9 +60,19 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel
return link; return link;
} }
// TODO: update validate method in PE version.
@Override @Override
public boolean hasDynamicSourceArguments() { public void validate() {
return arguments.values().stream().anyMatch(arg -> arg.getRefDynamicSource() != null); boolean hasDynamicSourceRelationQuery = arguments.values()
.stream()
.anyMatch(arg -> CFArgumentDynamicSourceType.RELATION_QUERY.equals(arg.getRefDynamicSource()));
if (hasDynamicSourceRelationQuery) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support arguments with 'RELATION_QUERY' dynamic source type!");
}
}
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalSec > 0 && arguments.values().stream().anyMatch(arg -> arg.getRefDynamicSource() != null);
} }
} }

11
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java

@ -59,16 +59,15 @@ public interface CalculatedFieldConfiguration {
CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId); CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId);
@JsonIgnore void validate();
boolean hasDynamicSourceArguments();
@JsonIgnore @JsonIgnore
default boolean isScheduledUpdateEnabled() { default boolean isScheduledUpdateEnabled() {
return hasDynamicSourceArguments() && getScheduledUpdateIntervalSec() > 0; return false;
} }
default int getScheduledUpdateIntervalSec() { void setScheduledUpdateIntervalSec(int scheduledUpdateIntervalSec);
return 0;
} int getScheduledUpdateIntervalSec();
} }

64
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java

@ -19,19 +19,77 @@ import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import java.util.Set;
import static org.thingsboard.server.common.data.cf.configuration.CFArgumentDynamicSourceType.RELATION_QUERY;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration { public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration {
private int refreshIntervalSec; public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
public static final String SAVE_ZONES_ARGUMENT_KEY = "saveZones";
public static final String RESTRICTED_ZONES_ARGUMENT_KEY = "restrictedZones";
private static final Set<String> requiredKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY,
SAVE_ZONES_ARGUMENT_KEY,
RESTRICTED_ZONES_ARGUMENT_KEY
);
@Override @Override
public CalculatedFieldType getType() { public CalculatedFieldType getType() {
return CalculatedFieldType.GEOFENCING; return CalculatedFieldType.GEOFENCING;
} }
public boolean isScheduledUpdateEnabled() { // TODO: update validate method in PE version.
return refreshIntervalSec > 0; @Override
public void validate() {
if (arguments == null || arguments.size() != 4) {
throw new IllegalArgumentException("Geofencing calculated field configuration must contain exactly 4 arguments: " + requiredKeys);
}
for (String requiredKey : requiredKeys) {
Argument argument = arguments.get(requiredKey);
if (argument == null) {
throw new IllegalArgumentException("Missing required argument: " + requiredKey);
}
ReferencedEntityKey refEntityKey = argument.getRefEntityKey();
if (refEntityKey == null || refEntityKey.getType() == null) {
throw new IllegalArgumentException("Missing or invalid reference entity key for argument: " + requiredKey);
}
switch (requiredKey) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument: '" + requiredKey + "' must be set to " + ArgumentType.TS_LATEST + " type!");
}
var dynamicSource = argument.getRefDynamicSource();
if (dynamicSource != null) {
String test = "test";
throw new IllegalArgumentException("Dynamic source configuration is forbidden for '" + requiredKey + "' argument. " +
"Only '" + SAVE_ZONES_ARGUMENT_KEY + "' and '" + RESTRICTED_ZONES_ARGUMENT_KEY + "' " +
"may use dynamic source configuration.");
}
}
case SAVE_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY -> {
if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument: '" + requiredKey + "' must be set to " + ArgumentType.ATTRIBUTE + " type!");
}
var dynamicSource = argument.getRefDynamicSource();
if (dynamicSource == null) {
continue;
}
if (!RELATION_QUERY.equals(dynamicSource)) {
throw new IllegalArgumentException("Only relation query dynamic source is supported for argument: " + requiredKey);
}
if (argument.getRefDynamicSourceConfiguration() == null) {
throw new IllegalArgumentException("Missing dynamic source configuration for: " + requiredKey);
}
argument.getRefDynamicSourceConfiguration().validate();
}
}
}
} }
} }

13
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java

@ -41,6 +41,19 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
return CFArgumentDynamicSourceType.RELATION_QUERY; return CFArgumentDynamicSourceType.RELATION_QUERY;
} }
@Override
public void validate() {
if (maxLevel > 2) {
throw new IllegalArgumentException("Relation query dynamic source configuration max relation level can't be greater than 2!");
}
if (direction == null) {
throw new IllegalArgumentException("Relation query dynamic source configuration direction must be specified!");
}
if (relationType == null) {
throw new IllegalArgumentException("Relation query dynamic source configuration relation type must be specified!");
}
}
@Override @Override
public boolean isSimpleRelation() { public boolean isSimpleRelation() {
return maxLevel == 1 && (profiles == null || profiles.isEmpty()); return maxLevel == 1 && (profiles == null || profiles.isEmpty());

4
common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java

@ -172,6 +172,10 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxCalculatedFieldsPerEntity = 5; private long maxCalculatedFieldsPerEntity = 5;
@Schema(example = "10") @Schema(example = "10")
private long maxArgumentsPerCF = 10; private long maxArgumentsPerCF = 10;
@Schema(example = "300")
private int minAllowedScheduledUpdateIntervalInSecForCF = 60;
@Schema(example = "300")
private int maxAllowedScheduledUpdateIntervalInSecForCF = 3600;
@Builder.Default @Builder.Default
@Min(value = 1, message = "must be at least 1") @Min(value = 1, message = "must be at least 1")
@Schema(example = "1000") @Schema(example = "1000")

12
dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java

@ -78,6 +78,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements
TenantId tenantId = calculatedField.getTenantId(); TenantId tenantId = calculatedField.getTenantId();
log.trace("Executing save calculated field, [{}]", calculatedField); log.trace("Executing save calculated field, [{}]", calculatedField);
updateDebugSettings(tenantId, calculatedField, System.currentTimeMillis()); updateDebugSettings(tenantId, calculatedField, System.currentTimeMillis());
updatedSchedulingConfiguration(calculatedField);
CalculatedField savedCalculatedField = calculatedFieldDao.save(tenantId, calculatedField); CalculatedField savedCalculatedField = calculatedFieldDao.save(tenantId, calculatedField);
createOrUpdateCalculatedFieldLink(tenantId, savedCalculatedField); createOrUpdateCalculatedFieldLink(tenantId, savedCalculatedField);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedCalculatedField.getTenantId()).entityId(savedCalculatedField.getId()) eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedCalculatedField.getTenantId()).entityId(savedCalculatedField.getId())
@ -91,6 +92,17 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements
} }
} }
private void updatedSchedulingConfiguration(CalculatedField calculatedField) {
CalculatedFieldConfiguration configuration = calculatedField.getConfiguration();
if (!configuration.isScheduledUpdateEnabled()) {
return;
}
var defaultProfileConfiguration = tbTenantProfileCache.get(calculatedField.getTenantId()).getDefaultProfileConfiguration();
int min = defaultProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF();
int max = defaultProfileConfiguration.getMaxAllowedScheduledUpdateIntervalInSecForCF();
configuration.setScheduledUpdateIntervalSec(Math.max(min, Math.min(configuration.getScheduledUpdateIntervalSec(), max)));
}
@Override @Override
public CalculatedField findById(TenantId tenantId, CalculatedFieldId calculatedFieldId) { public CalculatedField findById(TenantId tenantId, CalculatedFieldId calculatedFieldId) {
log.trace("Executing findById, tenantId [{}], calculatedFieldId [{}]", tenantId, calculatedFieldId); log.trace("Executing findById, tenantId [{}], calculatedFieldId [{}]", tenantId, calculatedFieldId);

2
dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java

@ -81,7 +81,7 @@ public abstract class AbstractEntityService {
@Autowired @Autowired
@Lazy @Lazy
private TbTenantProfileCache tbTenantProfileCache; protected TbTenantProfileCache tbTenantProfileCache;
@Value("${debug.settings.default_duration:15}") @Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes; private int defaultDebugDurationMinutes;

21
dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java

@ -18,6 +18,8 @@ package org.thingsboard.server.dao.service.validator;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
@ -39,7 +41,7 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) { protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) {
validateNumberOfCFsPerEntity(tenantId, calculatedField.getEntityId()); validateNumberOfCFsPerEntity(tenantId, calculatedField.getEntityId());
validateNumberOfArgumentsPerCF(tenantId, calculatedField); validateNumberOfArgumentsPerCF(tenantId, calculatedField);
validateArgumentNames(calculatedField); validateCalculatedFieldConfiguration(calculatedField);
} }
@Override @Override
@ -49,7 +51,7 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
throw new DataValidationException("Can't update non existing calculated field!"); throw new DataValidationException("Can't update non existing calculated field!");
} }
validateNumberOfArgumentsPerCF(tenantId, calculatedField); validateNumberOfArgumentsPerCF(tenantId, calculatedField);
validateArgumentNames(calculatedField); validateCalculatedFieldConfiguration(calculatedField);
return old; return old;
} }
@ -68,15 +70,26 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
if (maxArgumentsPerCF <= 0) { if (maxArgumentsPerCF <= 0) {
return; return;
} }
if (CalculatedFieldType.GEOFENCING.equals(calculatedField.getType()) && maxArgumentsPerCF < 4) {
throw new DataValidationException("Geofencing calculated field requires 4 arguments, but the system limit is " +
maxArgumentsPerCF + ". Contact your administrator to increase the limit."
);
}
if (calculatedField.getConfiguration().getArguments().size() > maxArgumentsPerCF) { if (calculatedField.getConfiguration().getArguments().size() > maxArgumentsPerCF) {
throw new DataValidationException("Calculated field arguments limit reached!"); throw new DataValidationException("Calculated field arguments limit reached!");
} }
} }
private void validateArgumentNames(CalculatedField calculatedField) { private void validateCalculatedFieldConfiguration(CalculatedField calculatedField) {
if (calculatedField.getConfiguration().getArguments().containsKey("ctx")) { CalculatedFieldConfiguration configuration = calculatedField.getConfiguration();
if (configuration.getArguments().containsKey("ctx")) {
throw new DataValidationException("Argument name 'ctx' is reserved and cannot be used."); throw new DataValidationException("Argument name 'ctx' is reserved and cannot be used.");
} }
try {
configuration.validate();
} catch (IllegalArgumentException e) {
throw new DataValidationException(e.getMessage(), e);
}
} }
} }

Loading…
Cancel
Save