Commit 665b44af authored by Matija Obreza's avatar Matija Obreza

Merge branch '388-kpi-executions-acl' into 'master'

Resolve "KPI Executions ACL"

Closes #388

See merge request genesys-pgr/genesys-server!308
parents f1c534b1 3e0bea73
......@@ -204,7 +204,7 @@ public class KPIController extends ApiBaseController {
*/
@RequestMapping(value = "/execution/{executionName:.+}", method = { RequestMethod.GET }, produces = { MediaType.APPLICATION_JSON_VALUE })
public @ResponseBody ExecutionJson getExecution(@PathVariable("executionName") String executionName) {
Execution execution = kpiService.getExecution(executionName);
Execution execution = kpiService.loadExecution(executionName);
return ExecutionJson.from(execution);
}
......
......@@ -133,7 +133,7 @@ public class KPIController {
@JsonView(JsonViews.Minimal.class)
@GetMapping(value = "/executions/{name}")
public ExecutionDetails executionDetails(@PathVariable String name) {
Execution execution = kpiService.getExecution(name);
Execution execution = kpiService.loadExecution(name);
return ExecutionDetails.from(execution, kpiService.findLastExecutionRun(execution), kpiService.listExecutionRuns(execution, new PageRequest(0, 10)));
}
......
......@@ -16,10 +16,7 @@
package org.genesys2.server.api.v1;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
......@@ -76,10 +73,11 @@ public class KPIReadController {
@RequestParam(value="date", required = true) @DateTimeFormat(pattern="yyyyMMdd") final Date date,
@RequestBody(required = false) final Map<String, Set<String>> keys) {
ArrayNode l=objectMapper.createArrayNode();
kpiService.filterObservations(executionName, date, keys).forEach(observation -> {
ArrayNode l = objectMapper.createArrayNode();
Execution execution = kpiService.getExecution(executionName);
kpiService.filterObservations(execution, date, keys).forEach(observation -> {
l.add(toMap(observation));
});;
});
return l;
}
......
/**
* Copyright 2014 Global Crop Diversity Trust
*
/*
* Copyright 2018 Global Crop Diversity Trust
*
* 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
......@@ -38,6 +38,7 @@ import javax.validation.constraints.Size;
import org.genesys.blocks.model.AuditedVersionedModel;
import org.genesys.blocks.model.SelfCleaning;
import org.genesys.blocks.security.model.AclAwareModel;
import com.fasterxml.jackson.annotation.JsonIgnore;
......@@ -49,7 +50,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
*/
@Entity
@Table(name = "kpiexecution")
public class Execution extends AuditedVersionedModel implements SelfCleaning {
public class Execution extends AuditedVersionedModel implements SelfCleaning, AclAwareModel {
/**
*
......
......@@ -70,7 +70,7 @@ public class ExecutionGroup implements SelfCleaning, Serializable {
/** The alias. */
@Size(max = 100)
@Pattern(regexp = "[_a-z][a-zA-Z0-9_]+")
@Pattern(regexp = "[_a-z][a-zA-Z0-9_\\.]+")
@Column(length = 100)
private String alias;
......
......@@ -34,7 +34,7 @@ public class KPIController {
@RequestMapping(value = "/exec/{executionName:.+}", method = RequestMethod.GET)
public String execution(ModelMap model, @PathVariable("executionName") String executionName,
@RequestParam(value = "page", required = false, defaultValue = "1") int page) {
Execution execution = kpiService.getExecution(executionName);
Execution execution = kpiService.loadExecution(executionName);
model.addAttribute("execution", execution);
model.addAttribute("executionRuns", kpiService.listExecutionRuns(execution, new PageRequest(page - 1, pageSize)));
return "/admin/kpi/execution";
......@@ -44,7 +44,7 @@ public class KPIController {
@RequestMapping(value = "/exec/{executionName:.+}", method = RequestMethod.GET, params={"dk"})
public String execution(ModelMap model, @PathVariable("executionName") String executionName,
@RequestParam(value = "dk", required = true) long dimensionKeyId) {
Execution execution = kpiService.getExecution(executionName);
Execution execution = kpiService.loadExecution(executionName);
model.addAttribute("execution", execution);
model.addAttribute("observations", kpiService.listObservations(execution, dimensionKeyId, new PageRequest(0, pageSize)));
return "/admin/kpi/execution";
......@@ -53,7 +53,7 @@ public class KPIController {
@RequestMapping(value = "/exec/{executionName}/run/{runId}", method = RequestMethod.GET)
public String executionRun(ModelMap model, @PathVariable("executionName") String executionName, @PathVariable("runId") long runId,
@RequestParam(value = "page", required = false, defaultValue = "1") int page) {
Execution execution = kpiService.getExecution(executionName);
Execution execution = kpiService.loadExecution(executionName);
ExecutionRun executionRun = kpiService.getExecutionRun(runId);
model.addAttribute("execution", execution);
model.addAttribute("executionRun", executionRun);
......
......@@ -53,8 +53,9 @@ public interface KPIService {
Execution save(@Valid Execution execution);
Execution getExecution(long id);
Execution loadExecution(String executionName);
Execution getExecution(long id);
Execution getExecution(String executionName);
Execution delete(Execution execution);
......@@ -80,7 +81,7 @@ public interface KPIService {
Page<Observation> listObservations(Execution execution, long dimensionKeyId, Pageable pageable);
List<Observation> filterObservations(String executionName, Date date, Map<String, Set<String>> keys);
List<Observation> filterObservations(Execution execution, Date date, Map<String, Set<String>> keys);
}
/**
* Copyright 2014 Global Crop Diversity Trust
*
/*
* Copyright 2018 Global Crop Diversity Trust
*
* 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.genesys2.server.service.impl;
import static org.genesys2.server.model.kpi.QDimensionKey.dimensionKey;
import static org.genesys2.server.model.kpi.QExecution.execution;
import static org.genesys2.server.model.kpi.QExecutionRun.executionRun;
import static org.genesys2.server.model.kpi.QObservation.observation;
......@@ -39,6 +40,7 @@ import javax.validation.Valid;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.genesys2.server.model.UserRole;
import org.genesys2.server.model.kpi.Dimension;
import org.genesys2.server.model.kpi.DimensionKey;
import org.genesys2.server.model.kpi.Execution;
......@@ -53,14 +55,18 @@ import org.genesys2.server.persistence.kpi.ExecutionRepository;
import org.genesys2.server.persistence.kpi.ExecutionRunRepository;
import org.genesys2.server.persistence.kpi.KPIParameterRepository;
import org.genesys2.server.persistence.kpi.ObservationRepository;
import org.genesys2.server.security.SecurityUtils;
import org.genesys2.server.service.KPIService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.acls.domain.BasePermission;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
......@@ -95,6 +101,10 @@ public class KPIServiceImpl implements KPIService {
@Autowired
private ExecutionRunRepository executionRunRepository;
/** The securityUtils. */
@Autowired
private SecurityUtils securityUtils;
@Autowired
private JPAQueryFactory jpaQueryFactory;
......@@ -167,24 +177,33 @@ public class KPIServiceImpl implements KPIService {
return dimensionRepository.findAll(page);
}
@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || hasPermission(returnObject, 'READ')")
@Override
public Execution getExecution(long id) {
return lazyLoad(executionRepository.findOne(id));
return executionRepository.findOne(id);
}
@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || hasPermission(returnObject, 'READ')")
@Override
public Execution loadExecution(String executionName) {
return lazyLoad(getExecution(executionName));
}
@PostAuthorize("hasRole('ADMINISTRATOR') || returnObject==null || hasPermission(returnObject, 'READ')")
@Override
public Execution getExecution(String executionName) {
return lazyLoad(executionRepository.findByName(executionName));
return executionRepository.findByName(executionName);
}
@Override
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION') or hasPermission(#execution, 'READ')")
public ExecutionRun findLastExecutionRun(Execution execution) {
List<ExecutionRun> l = executionRunRepository.findLast(execution, new PageRequest(0, 1));
ExecutionRun lastRun = l.size() == 1 ? l.get(0) : null;
return lazyLoad(lastRun);
}
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION')")
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION') or hasPermission(#execution, 'WRITE')")
@Override
@Transactional
public Execution save(@Valid Execution execution) {
......@@ -199,10 +218,18 @@ public class KPIServiceImpl implements KPIService {
@Override
public Page<Execution> listExecutions(Pageable page) {
return executionRepository.findAll(page);
if (securityUtils.hasRole(UserRole.ADMINISTRATOR)) {
return executionRepository.findAll(page);
}
Set<Long> executionIds = new HashSet<>(securityUtils.listObjectIdentityIdsForCurrentUser(Execution.class, BasePermission.READ));
JPAQuery<Execution> query = jpaQueryFactory.selectFrom(execution).where(execution.id.in(executionIds));
long total = query.fetchCount();
List<Execution> content = query.offset(page.getOffset()).limit(page.getPageSize()).fetch();
return new PageImpl<>(content, page, total);
}
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION')")
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION') or hasPermission(#execution, 'DELETE')")
@Override
@Transactional
public Execution delete(Execution execution) {
......@@ -214,7 +241,7 @@ public class KPIServiceImpl implements KPIService {
return execution;
}
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION')")
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#executionRun.execution, 'ADMINISTRATION') or hasPermission(#executionRun.execution, 'DELETE')")
@Override
@Transactional
public void deleteObservations(ExecutionRun executionRun) {
......@@ -306,7 +333,7 @@ public class KPIServiceImpl implements KPIService {
}
// readonly mode
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION')")
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION') or hasPermission(#execution, 'READ')")
@Override
public List<Observation> execute(Execution execution) {
List<Observation> results = new ArrayList<Observation>();
......@@ -319,7 +346,7 @@ public class KPIServiceImpl implements KPIService {
return results;
}
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION')")
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'ADMINISTRATION') or hasPermission(#execution, 'WRITE')")
@Override
@Transactional
public ExecutionRun executeAndSave(Execution execution) {
......@@ -495,6 +522,7 @@ public class KPIServiceImpl implements KPIService {
}
@Override
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#executionRun.execution, 'READ')")
public Page<Observation> listObservations(ExecutionRun executionRun, Map<String, String> dimensionFilters, Pageable page) {
Page<Observation> res = null;
if (dimensionFilters == null || dimensionFilters.isEmpty()) {
......@@ -514,6 +542,7 @@ public class KPIServiceImpl implements KPIService {
}
@Override
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'READ')")
public Page<Observation> listObservations(Execution execution, long dimensionKeyId, Pageable pageable) {
DimensionKey dk = dimensionKeyRepository.findOne(dimensionKeyId);
Page<Observation> res = observationRepository.listObservationsByDimensionKey(execution, dk, pageable);
......@@ -527,11 +556,13 @@ public class KPIServiceImpl implements KPIService {
}
@Override
public List<Observation> filterObservations(final String executionName, final Date date, final Map<String, Set<String>> keys) {
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'READ')")
public List<Observation> filterObservations(final Execution execution, final Date date, final Map<String, Set<String>> keys) {
JPAQuery<ExecutionRun> executionQuery = jpaQueryFactory.selectFrom(executionRun);
executionQuery.where(executionRun.execution.name.eq(executionName).and(executionRun.timestamp.before(date)));
executionQuery.where(executionRun.execution.eq(execution).and(executionRun.timestamp.before(date)));
executionQuery.orderBy(executionRun.timestamp.desc());
ExecutionRun executionRun = executionQuery.fetchFirst();
if (executionRun == null) {
return Collections.emptyList();
}
......@@ -549,7 +580,6 @@ public class KPIServiceImpl implements KPIService {
}
private Set<DimensionKey> getDimensionKeys(Map<String, String> dimensionFilters) {
// TODO needs .equals()?
Set<DimensionKey> dks = new HashSet<DimensionKey>();
for (String name : dimensionFilters.keySet()) {
String value = dimensionFilters.get(name);
......@@ -566,11 +596,13 @@ public class KPIServiceImpl implements KPIService {
}
@Override
@PreAuthorize("hasRole('ADMINISTRATOR') or hasPermission(#execution, 'READ')")
public List<ExecutionRun> listExecutionRuns(Execution execution, Pageable pageable) {
return executionRunRepository.findLast(execution, pageable);
}
@Override
@PostAuthorize("hasRole('ADMINISTRATOR') or hasPermission(returnObject.execution, 'READ')")
public ExecutionRun getExecutionRun(long runId) {
return lazyLoad(executionRunRepository.findOne(runId));
}
......
/*
* Copyright 2018 Global Crop Diversity Trust
*
* 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.genesys.test.server.api.v1;
import static org.hamcrest.Matchers.*;
......@@ -32,6 +48,7 @@ import org.genesys2.server.service.KPIService;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.transaction.annotation.Transactional;
import com.fasterxml.jackson.annotation.JsonInclude;
......@@ -106,6 +123,7 @@ public class KPIReadControllerTest extends AbstractApiTest {
}
@Test
@WithMockUser(username = "user", password = "user", roles = "ADMINISTRATOR")
public void testList() throws Exception {
ExecutionRun executionRun = buildAndSave("institute.count");
assertThat(executionRun.getExecution().getName(), is("institute.count"));
......@@ -118,6 +136,7 @@ public class KPIReadControllerTest extends AbstractApiTest {
}
@Test
@WithMockUser(username = "user", password = "user", roles = "ADMINISTRATOR")
public void testFilterObservations() throws Exception {
ExecutionRun executionRun = buildAndSave("institute.count");
assertThat(executionRun.getObservations().size(), is(10));
......@@ -139,6 +158,7 @@ public class KPIReadControllerTest extends AbstractApiTest {
}
@Test
@WithMockUser(username = "user", password = "user", roles = "ADMINISTRATOR")
public void testEmptyListIfNotFound() throws Exception {
ExecutionRun executionRun = buildAndSave("institute.count");
LocalDateTime ldt = LocalDateTime.now().plusMonths(-10);
......
......@@ -16,13 +16,10 @@
package org.genesys.test.server.services;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThat;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.*;
import java.util.Calendar;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
......@@ -297,7 +294,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionCountByBoolean1() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
BooleanDimension dim1 = kpiService.save(createBoolDimension("yesno", "Boolean yes and no dimension"));
......@@ -305,7 +302,7 @@ public class KPIServiceTest extends AbstractServicesTest {
Execution loaded = kpiService.save(exec1);
assertThat(loaded, notNullValue());
assertThat(loaded.getId(), notNullValue());
assertThat(loaded.getName(), is("institute.count"));
assertThat(loaded.getName(), is(EXECUTION_NAME));
assertThat(loaded.getTitle(), is("Institute count"));
// defaults
assertThat(loaded.getProperty(), is("id"));
......@@ -332,7 +329,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionAverageByBoolean1() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setType(ExecutionType.AVERAGE);
......@@ -365,7 +362,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testFilterObservations() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setType(ExecutionType.AVERAGE);
......@@ -406,7 +403,7 @@ public class KPIServiceTest extends AbstractServicesTest {
Map<String, Set<String>> map = new HashMap<>();
map.put("allowMaterialRequests", Sets.newHashSet("true"));
map.put("code", Sets.newHashSet("INS1"));
List<Observation> observationList = kpiService.filterObservations(EXECUTION_NAME, searchDate.getTime(), map);
List<Observation> observationList = kpiService.filterObservations(loaded, searchDate.getTime(), map);
assertThat(observationList.size(), is(1));
assertTrue(run1.getObservations().stream().anyMatch(obs -> obs.getId().equals(observationList.get(0).getId())));
......@@ -418,7 +415,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionAverageByBooleanAndJpa() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setType(ExecutionType.AVERAGE);
......@@ -460,7 +457,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionLinkedCollection() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setLink("networks"); // @OneToMany in FaoInstitute
......@@ -468,7 +465,7 @@ public class KPIServiceTest extends AbstractServicesTest {
Execution loaded = kpiService.save(exec1);
assertThat(loaded, notNullValue());
assertThat(loaded.getId(), notNullValue());
assertThat(loaded.getName(), is("institute.count"));
assertThat(loaded.getName(), is(EXECUTION_NAME));
assertThat(loaded.getTitle(), is("Institute count"));
// defaults
assertThat(loaded.getProperty(), is("slug"));
......@@ -491,7 +488,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionLinkedCollectionCountByBoolean1() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setLink("networks"); // @OneToMany in FaoInstitute
......@@ -501,7 +498,7 @@ public class KPIServiceTest extends AbstractServicesTest {
Execution loaded = kpiService.save(exec1);
assertThat(loaded, notNullValue());
assertThat(loaded.getId(), notNullValue());
assertThat(loaded.getName(), is("institute.count"));
assertThat(loaded.getName(), is(EXECUTION_NAME));
assertThat(loaded.getTitle(), is("Institute count"));
// defaults
assertThat(loaded.getProperty(), is("slug"));
......@@ -530,7 +527,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionGroupingComplex() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setProperty("id");
......@@ -543,7 +540,7 @@ public class KPIServiceTest extends AbstractServicesTest {
Execution loaded = kpiService.save(exec1);
assertThat(loaded, notNullValue());
assertThat(loaded.getId(), notNullValue());
assertThat(loaded.getName(), is("institute.count"));
assertThat(loaded.getName(), is(EXECUTION_NAME));
assertThat(loaded.getTitle(), is("Institute count"));
// defaults
assertThat(loaded.getProperty(), is("slug"));
......@@ -583,7 +580,7 @@ public class KPIServiceTest extends AbstractServicesTest {
public void testExecutionGroup1() {
KPIParameter param1 = kpiService.save(createParameter("institute", FaoInstitute.class, "Institute"));
Execution exec1 = new Execution();
exec1.setName("institute.count");
exec1.setName(EXECUTION_NAME);
exec1.setTitle("Institute count");
exec1.setParameter(param1);
exec1.setProperty("id");
......@@ -592,7 +589,7 @@ public class KPIServiceTest extends AbstractServicesTest {
Execution loaded = kpiService.save(exec1);
assertThat(loaded, notNullValue());
assertThat(loaded.getId(), notNullValue());
assertThat(loaded.getName(), is("institute.count"));
assertThat(loaded.getName(), is(EXECUTION_NAME));
assertThat(loaded.getTitle(), is("Institute count"));
// defaults
assertThat(loaded.getProperty(), is("id"));
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment