*/
package org.sonar.db.user;
+import java.time.OffsetDateTime;
+import java.time.temporal.ChronoUnit;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import org.apache.commons.lang.StringUtils;
private final String searchText;
private final Boolean isActive;
private final String isManagedSqlClause;
+ private final Long lastConnectionDateFrom;
+ private final Long lastConnectionDateTo;
+ private final Long sonarLintLastConnectionDateFrom;
+ private final Long sonarLintLastConnectionDateTo;
- public UserQuery(@Nullable String searchText, @Nullable Boolean isActive, @Nullable String isManagedSqlClause) {
+ public UserQuery(@Nullable String searchText, @Nullable Boolean isActive, @Nullable String isManagedSqlClause,
+ @Nullable OffsetDateTime lastConnectionDateFrom, @Nullable OffsetDateTime lastConnectionDateTo,
+ @Nullable OffsetDateTime sonarLintLastConnectionDateFrom, @Nullable OffsetDateTime sonarLintLastConnectionDateTo) {
this.searchText = searchTextToSearchTextSql(searchText);
this.isActive = isActive;
this.isManagedSqlClause = isManagedSqlClause;
+ this.lastConnectionDateFrom = parseDateToLong(lastConnectionDateFrom);
+ this.lastConnectionDateTo = formatDateToInput(lastConnectionDateTo);
+ this.sonarLintLastConnectionDateFrom = parseDateToLong(sonarLintLastConnectionDateFrom);
+ this.sonarLintLastConnectionDateTo = formatDateToInput(sonarLintLastConnectionDateTo);
+ }
+
+ private static Long formatDateToInput(@Nullable OffsetDateTime dateTo) {
+ if(dateTo == null) {
+ return null;
+ } else {
+ // add 1 second to include all timestamp at the second precision.
+ return dateTo.toInstant().plus(1, ChronoUnit.SECONDS).toEpochMilli();
+ }
+ }
+ private static Long parseDateToLong(@Nullable OffsetDateTime date) {
+ if(date == null) {
+ return null;
+ } else {
+ return date.toInstant().toEpochMilli();
+ }
}
private static String searchTextToSearchTextSql(@Nullable String text) {
return isManagedSqlClause;
}
+ @CheckForNull
+ public Long getLastConnectionDateFrom() {
+ return lastConnectionDateFrom;
+ }
+
+ @CheckForNull
+ public Long getLastConnectionDateTo() {
+ return lastConnectionDateTo;
+ }
+ @CheckForNull
+ public Long getSonarLintLastConnectionDateFrom() {
+ return sonarLintLastConnectionDateFrom;
+ }
+ @CheckForNull
+ public Long getSonarLintLastConnectionDateTo() {
+ return sonarLintLastConnectionDateTo;
+ }
+
public static UserQueryBuilder builder() {
return new UserQueryBuilder();
}
private String searchText = null;
private Boolean isActive = null;
private String isManagedSqlClause = null;
+ private OffsetDateTime lastConnectionDateFrom = null;
+ private OffsetDateTime lastConnectionDateTo = null;
+ private OffsetDateTime sonarLintLastConnectionDateFrom = null;
+ private OffsetDateTime sonarLintLastConnectionDateTo = null;
+
private UserQueryBuilder() {
}
return this;
}
+ public UserQueryBuilder lastConnectionDateFrom(@Nullable OffsetDateTime lastConnectionDateFrom) {
+ this.lastConnectionDateFrom = lastConnectionDateFrom;
+ return this;
+ }
+
+ public UserQueryBuilder lastConnectionDateTo(@Nullable OffsetDateTime lastConnectionDateTo) {
+ this.lastConnectionDateTo = lastConnectionDateTo;
+ return this;
+ }
+
+ public UserQueryBuilder sonarLintLastConnectionDateFrom(@Nullable OffsetDateTime sonarLintLastConnectionDateFrom) {
+ this.sonarLintLastConnectionDateFrom = sonarLintLastConnectionDateFrom;
+ return this;
+ }
+
+ public UserQueryBuilder sonarLintLastConnectionDateTo(@Nullable OffsetDateTime sonarLintLastConnectionDateTo) {
+ this.sonarLintLastConnectionDateTo = sonarLintLastConnectionDateTo;
+ return this;
+ }
+
public UserQuery build() {
- return new UserQuery(searchText, isActive, isManagedSqlClause);
+ return new UserQuery(
+ searchText, isActive, isManagedSqlClause, lastConnectionDateFrom, lastConnectionDateTo,
+ sonarLintLastConnectionDateFrom, sonarLintLastConnectionDateTo);
}
}
}
*/
package org.sonar.server.user.ws;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.Rule;
import org.junit.Test;
import org.sonar.api.server.ws.WebService;
import org.sonar.api.server.ws.WebService.Param;
+import org.sonar.api.utils.DateUtils;
import org.sonar.core.util.UuidFactory;
import org.sonar.db.DbTester;
import org.sonar.db.scim.ScimUserDao;
.setScmAccounts(emptyList())
.setExternalLogin("fmallet")
.setExternalIdentityProvider("sonarqube"));
+ long lastConnection = DateUtils.parseOffsetDateTime("2019-03-27T09:51:50+0100").toInstant().toEpochMilli();
+ fmallet = db.users().updateLastConnectionDate(fmallet, lastConnection);
+ fmallet = db.users().updateSonarLintLastConnectionDate(fmallet, lastConnection);
UserDto simon = db.users().insertUser(u -> u.setLogin("sbrandhof").setName("Simon").setEmail("s.brandhof@company.tld")
.setLocal(false)
.setExternalLogin("sbrandhof@ldap.com")
.setExternalIdentityProvider("sonarqube")
.setScmAccounts(asList("simon.brandhof", "s.brandhof@company.tld")));
+
mockUsersAsManaged(simon.getUuid());
GroupDto sonarUsers = db.users().insertGroup("sonar-users");
assertThat(action).isNotNull();
assertThat(action.isPost()).isFalse();
assertThat(action.responseExampleAsString()).isNotEmpty();
- assertThat(action.params()).hasSize(5);
+ assertThat(action.params()).hasSize(9);
+ }
+
+ @Test
+ public void search_whenFilteringConnectionDate_shouldApplyFilter() {
+ userSession.logIn().setSystemAdministrator();
+ final Instant lastConnection = Instant.now();
+ UserDto user = db.users().insertUser(u -> u
+ .setLogin("user-%_%-login")
+ .setName("user-name")
+ .setEmail("user@mail.com")
+ .setLocal(true)
+ .setScmAccounts(singletonList("user1")));
+ user = db.users().updateLastConnectionDate(user, lastConnection.toEpochMilli());
+ user = db.users().updateSonarLintLastConnectionDate(user, lastConnection.toEpochMilli());
+
+ assertThat(ws.newRequest()
+ .setParam("q", "user-%_%-")
+ .executeProtobuf(SearchWsResponse.class).getUsersList())
+ .extracting(User::getLogin)
+ .containsExactlyInAnyOrder(user.getLogin());
+
+ assertUserWithFilter("lastConnectedAfter", lastConnection.minus(1, ChronoUnit.DAYS), user.getLogin(), true);
+ assertUserWithFilter("lastConnectedAfter", lastConnection.plus(1, ChronoUnit.DAYS), user.getLogin(), false);
+ assertUserWithFilter("lastConnectedBefore", lastConnection.minus(1, ChronoUnit.DAYS), user.getLogin(), false);
+ assertUserWithFilter("lastConnectedBefore", lastConnection.plus(1, ChronoUnit.DAYS), user.getLogin(), true);
+
+ assertUserWithFilter("slLastConnectedAfter", lastConnection.minus(1, ChronoUnit.DAYS), user.getLogin(), true);
+ assertUserWithFilter("slLastConnectedAfter", lastConnection.plus(1, ChronoUnit.DAYS), user.getLogin(), false);
+ assertUserWithFilter("slLastConnectedBefore", lastConnection.minus(1, ChronoUnit.DAYS), user.getLogin(), false);
+ assertUserWithFilter("slLastConnectedBefore", lastConnection.plus(1, ChronoUnit.DAYS), user.getLogin(), true);
+
+ assertUserWithFilter("slLastConnectedAfter", lastConnection, user.getLogin(), true);
+ assertUserWithFilter("slLastConnectedBefore", lastConnection, user.getLogin(), true);
+ }
+
+ private void assertUserWithFilter(String field, Instant filterValue, String userLogin, boolean isExpectedToBeThere) {
+ var assertion = assertThat(ws.newRequest()
+ .setParam("q", "user-%_%-")
+ .setParam(field, DateUtils.formatDateTime(filterValue.toEpochMilli()))
+ .executeProtobuf(SearchWsResponse.class).getUsersList());
+ if (isExpectedToBeThere) {
+ assertion
+ .extracting(User::getLogin)
+ .containsExactlyInAnyOrder(userLogin);
+ } else {
+ assertion.isEmpty();
+ }
}
private void mockUsersAsManaged(String... userUuids) {
package org.sonar.server.user.ws;
import com.google.common.collect.Multimap;
+import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.sonar.api.server.ws.Request;
import org.sonar.api.server.ws.Response;
import org.sonar.api.server.ws.WebService;
+import org.sonar.api.utils.DateUtils;
+import org.sonar.api.utils.MessageException;
import org.sonar.api.utils.Paging;
import org.sonar.db.DbClient;
import org.sonar.db.DbSession;
import org.sonar.db.user.UserQuery;
import org.sonar.server.es.SearchOptions;
import org.sonar.server.exceptions.BadRequestException;
+import org.sonar.server.exceptions.ServerException;
import org.sonar.server.issue.AvatarResolver;
import org.sonar.server.management.ManagedInstanceService;
import org.sonar.server.user.UserSession;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.emptyToNull;
-import static java.util.Comparator.comparing;
import static java.lang.Boolean.TRUE;
+import static java.util.Comparator.comparing;
import static java.util.Optional.ofNullable;
import static org.sonar.api.server.ws.WebService.Param.PAGE;
import static org.sonar.api.server.ws.WebService.Param.PAGE_SIZE;
import static org.sonar.api.server.ws.WebService.Param.TEXT_QUERY;
-import static org.sonar.api.utils.DateUtils.formatDateTime;
import static org.sonar.api.utils.Paging.forPageIndex;
import static org.sonar.core.util.stream.MoreCollectors.toList;
import static org.sonar.server.ws.WsUtils.writeProtobuf;
public class SearchAction implements UsersWsAction {
private static final String DEACTIVATED_PARAM = "deactivated";
private static final String MANAGED_PARAM = "managed";
- private static final int MAX_PAGE_SIZE = 500;
+
+ private static final int MAX_PAGE_SIZE = 500;
+ private static final String LAST_CONNECTION_DATE_FROM = "lastConnectedAfter";
+ private static final String LAST_CONNECTION_DATE_TO = "lastConnectedBefore";
+ private static final String SONAR_LINT_LAST_CONNECTION_DATE_FROM = "slLastConnectedAfter";
+ private static final String SONAR_LINT_LAST_CONNECTION_DATE_TO = "slLastConnectedBefore";
private final UserSession userSession;
private final DbClient dbClient;
private final AvatarResolver avatarResolver;
" <li>'externalProvider'</li>" +
" <li>'groups'</li>" +
" <li>'lastConnectionDate'</li>" +
+ " <li>'sonarLintLastConnectionDate'</li>" +
" <li>'tokensCount'</li>" +
"</ul>" +
"Field 'lastConnectionDate' is only updated every hour, so it may not be accurate, for instance when a user authenticates many times in less than one hour.")
.setSince("3.6")
.setChangelog(
+ new Change("10.1", "New optional parameters " + SONAR_LINT_LAST_CONNECTION_DATE_FROM +
+ " and " + SONAR_LINT_LAST_CONNECTION_DATE_TO + " to filter users by SonarLint last connection date"),
+ new Change("10.1", "New optional parameters " + LAST_CONNECTION_DATE_FROM +
+ " and " + LAST_CONNECTION_DATE_TO + " to filter users by SonarQube last connection date"),
+ new Change("10.1", "New field 'sonarLintLastConnectionDate' is added to response"),
new Change("10.0", "'q' parameter values is now always performing a case insensitive match"),
new Change("10.0", "New parameter 'managed' to optionally search by managed status"),
new Change("10.0", "Response includes 'managed' field."),
action.addPagingParams(50, SearchOptions.MAX_PAGE_SIZE);
+ final String dateExample = "2020-01-01T00:00:00+0100";
action.createParam(TEXT_QUERY)
.setMinimumLength(2)
.setDescription("Filter on login, name and email.<br />" +
.setRequired(false)
.setDefaultValue(null)
.setBooleanPossibleValues();
+ action.createParam(LAST_CONNECTION_DATE_FROM)
+ .setSince("10.1")
+ .setDescription("""
+ Filter the users based on the last connection date field. Only users who interacted with this instance at or after the date will be returned.
+ The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)""")
+ .setRequired(false)
+ .setDefaultValue(null)
+ .setExampleValue(dateExample);
+ action.createParam(LAST_CONNECTION_DATE_TO)
+ .setSince("10.1")
+ .setDescription("""
+ Filter the users based on the last connection date field. Only users who interacted with this instance at or before the date will be returned.
+ The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)""")
+ .setRequired(false)
+ .setDefaultValue(null)
+ .setExampleValue(dateExample);
+ action.createParam(SONAR_LINT_LAST_CONNECTION_DATE_FROM)
+ .setSince("10.1")
+ .setDescription("""
+ Filter the users based on the sonar lint last connection date field. Only users who interacted with this instance using SonarLint at or after the date will be returned.
+ The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)""")
+ .setRequired(false)
+ .setDefaultValue(null)
+ .setExampleValue(dateExample);
+ action.createParam(SONAR_LINT_LAST_CONNECTION_DATE_TO)
+ .setSince("10.1")
+ .setDescription("""
+ Filter the users based on the sonar lint last connection date field. Only users who interacted with this instance using SonarLint at or before the date will be returned.
+ The format must be ISO 8601 datetime format (YYYY-MM-DDThh:mm:ss±hhmm)""")
+ .setRequired(false)
+ .setDefaultValue(null)
+ .setExampleValue(dateExample);
}
@Override
}
private UserQuery buildUserQuery(SearchRequest request) {
+ UserQuery.UserQueryBuilder builder = UserQuery.builder();
+ request.getLastConnectionDateFrom().ifPresent(builder::lastConnectionDateFrom);
+ request.getLastConnectionDateTo().ifPresent(builder::lastConnectionDateTo);
+ request.getSonarLintLastConnectionDateFrom().ifPresent(builder::sonarLintLastConnectionDateFrom);
+ request.getSonarLintLastConnectionDateTo().ifPresent(builder::sonarLintLastConnectionDateTo);
+
if (managedInstanceService.isInstanceExternallyManaged()) {
String managedInstanceSql = Optional.ofNullable(request.isManaged())
.map(managedInstanceService::getManagedUsersSqlFilter)
.orElse(null);
- return UserQuery.builder()
- .isActive(!request.isDeactivated())
- .searchText(request.getQuery())
- .isManagedClause(managedInstanceSql)
- .build();
- }
- if (request.isManaged() != null) {
+ builder.isManagedClause(managedInstanceSql);
+ } else if (request.isManaged() != null) {
throw BadRequestException.create("The 'managed' parameter is only available for managed instances.");
}
- return UserQuery.builder()
+
+ return builder
.isActive(!request.isDeactivated())
.searchText(request.getQuery())
.build();
}
ofNullable(user.getExternalLogin()).ifPresent(userBuilder::setExternalIdentity);
ofNullable(tokensCount).ifPresent(userBuilder::setTokensCount);
- ofNullable(user.getLastConnectionDate()).ifPresent(date -> userBuilder.setLastConnectionDate(formatDateTime(date)));
+ ofNullable(user.getLastConnectionDate()).map(DateUtils::formatDateTime).ifPresent(userBuilder::setLastConnectionDate);
+ ofNullable(user.getLastSonarlintConnectionDate())
+ .map(DateUtils::formatDateTime).ifPresent(userBuilder::setSonarLintLastConnectionDate);
userBuilder.setManaged(TRUE.equals(managed));
}
return userBuilder.build();
.setQuery(request.param(TEXT_QUERY))
.setDeactivated(request.mandatoryParamAsBoolean(DEACTIVATED_PARAM))
.setManaged(request.paramAsBoolean(MANAGED_PARAM))
+ .setLastConnectionDateFrom(request.param(LAST_CONNECTION_DATE_FROM))
+ .setLastConnectionDateTo(request.param(LAST_CONNECTION_DATE_TO))
+ .setSonarLintLastConnectionDateFrom(request.param(SONAR_LINT_LAST_CONNECTION_DATE_FROM))
+ .setSonarLintLastConnectionDateTo(request.param(SONAR_LINT_LAST_CONNECTION_DATE_TO))
.setPage(request.mandatoryParamAsInt(PAGE))
.setPageSize(pageSize)
.build();
private final String query;
private final boolean deactivated;
private final Boolean managed;
+ private final OffsetDateTime lastConnectionDateFrom;
+ private final OffsetDateTime lastConnectionDateTo;
+ private final OffsetDateTime sonarLintLastConnectionDateFrom;
+ private final OffsetDateTime sonarLintLastConnectionDateTo;
private SearchRequest(Builder builder) {
this.page = builder.page;
this.query = builder.query;
this.deactivated = builder.deactivated;
this.managed = builder.managed;
+ try {
+ this.lastConnectionDateFrom = Optional.ofNullable(builder.lastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null);
+ this.lastConnectionDateTo = Optional.ofNullable(builder.lastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null);
+ this.sonarLintLastConnectionDateFrom = Optional.ofNullable(builder.sonarLintLastConnectionDateFrom).map(DateUtils::parseOffsetDateTime).orElse(null);
+ this.sonarLintLastConnectionDateTo = Optional.ofNullable(builder.sonarLintLastConnectionDateTo).map(DateUtils::parseOffsetDateTime).orElse(null);
+ } catch (MessageException me) {
+ throw new ServerException(400, me.getMessage());
+ }
}
public Integer getPage() {
return managed;
}
+ public Optional<OffsetDateTime> getLastConnectionDateFrom() {
+ return Optional.ofNullable(lastConnectionDateFrom);
+ }
+
+ public Optional<OffsetDateTime> getLastConnectionDateTo() {
+ return Optional.ofNullable(lastConnectionDateTo);
+ }
+
+ public Optional<OffsetDateTime> getSonarLintLastConnectionDateFrom() {
+ return Optional.ofNullable(sonarLintLastConnectionDateFrom);
+ }
+
+ public Optional<OffsetDateTime> getSonarLintLastConnectionDateTo() {
+ return Optional.ofNullable(sonarLintLastConnectionDateTo);
+ }
+
public static Builder builder() {
return new Builder();
}
private String query;
private boolean deactivated;
private Boolean managed;
+ private String lastConnectionDateFrom;
+ private String lastConnectionDateTo;
+ private String sonarLintLastConnectionDateFrom;
+ private String sonarLintLastConnectionDateTo;
+
private Builder() {
// enforce factory method use
return this;
}
+ public Builder setLastConnectionDateFrom(@Nullable String lastConnectionDateFrom) {
+ this.lastConnectionDateFrom = lastConnectionDateFrom;
+ return this;
+ }
+
+ public Builder setLastConnectionDateTo(@Nullable String lastConnectionDateTo) {
+ this.lastConnectionDateTo = lastConnectionDateTo;
+ return this;
+ }
+
+ public Builder setSonarLintLastConnectionDateFrom(@Nullable String sonarLintLastConnectionDateFrom) {
+ this.sonarLintLastConnectionDateFrom = sonarLintLastConnectionDateFrom;
+ return this;
+ }
+
+ public Builder setSonarLintLastConnectionDateTo(@Nullable String sonarLintLastConnectionDateTo) {
+ this.sonarLintLastConnectionDateTo = sonarLintLastConnectionDateTo;
+ return this;
+ }
+
public SearchRequest build() {
return new SearchRequest(this);
}