From 664c4679208319b4168fa23de79245d33e05cff8 Mon Sep 17 00:00:00 2001 From: Bondar Bogdan <36962546+bogdan-bondar@users.noreply.github.com> Date: Wed, 13 Mar 2019 13:35:22 +0100 Subject: [PATCH] Feature soft autoassignment (#789) * Repository layer and database migration * Changed target filter management to update auto assignment action type together with distribution set, extended mgmt API, adapted auto assign checker/scheduler, changed auto assign distribution set select table to combobox, added filter functionality (needs refactoring) * Refactored auto assignment dialog window, added soft/forced option group, restructured action type option group layout * Added forced icon to target filter table autoassignment cell for the forced auto assign actions * First working draft of distribution set select combobox for auto assignment window * Optimised filtering queries, added alphabetical sorting on distribution set name and version * Refactoring of distribution bean query for lazy loading * Distribution set combobox refactoring and comments * Added verification of auto assign distribution set validity (completed, not deleted), exdended target filter query fields with auto assign action type field, added rsql filter tests, added repository layer tests * Added mgmt API tests * Changed target filter rest docu tests to include auto-assignment type * Updated hawkbit docs with auto-assignment description * Added debouncing to key and value input fields of metadata popup layout to get rid of unnecessary value change events, removed redundant set value call in common dialog window, minimizing the repaint components calls * Fixed sonar findings * Reverted changes of common dialog window validaton, setting the value of the field explicitly as before, until we rethink the whole concept of validaton mechanism * Fixed review findings * Removed rsql filtering by filter auto-assign action type, due to missing conversion of disallowed timeforced action type * Small fix regarding usage of page request * Updated sql script version for flyway * Extended tests for soft deleted distribution sets for auto-assignment and filter string within distribution set specification builder Signed-off-by: Bogdan Bondar --- docs/content/ui.md | 10 + .../ui/target_filter_auto_assignment.png | Bin 0 -> 112166 bytes .../hawkbit/exception/SpServerError.java | 104 +++-- .../repository/TargetFilterQueryFields.java | 2 +- .../repository/RepositoryManagement.java | 3 +- .../TargetFilterQueryManagement.java | 49 ++- .../builder/TargetFilterQueryCreate.java | 18 +- .../exception/EntityNotFoundException.java | 8 +- .../InvalidAutoAssignActionTypeException.java | 29 ++ ...lidAutoAssignDistributionSetException.java | 30 ++ .../model/DistributionSetFilter.java | 12 + .../repository/model/TargetFilterQuery.java | 17 + ...AbstractTargetFilterQueryUpdateCreate.java | 22 +- .../jpa/JpaDistributionSetManagement.java | 27 +- .../jpa/JpaTargetFilterQueryManagement.java | 54 ++- .../jpa/TargetFilterQueryRepository.java | 7 +- .../jpa/autoassign/AutoAssignChecker.java | 52 ++- .../builder/JpaTargetFilterQueryCreate.java | 14 +- .../jpa/model/JpaTargetFilterQuery.java | 30 +- .../DistributionSetSpecification.java | 16 + .../jpa/utils/DeploymentHelper.java | 21 + ..._11__add_auto_assign_action_type___DB2.sql | 1 + ...2_11__add_auto_assign_action_type___H2.sql | 1 + ...1__add_auto_assign_action_type___MYSQL.sql | 1 + ...d_auto_assign_action_type___SQL_SERVER.sql | 1 + .../jpa/DistributionSetManagementTest.java | 362 +++++++++++------- .../jpa/TargetFilterQueryManagementTest.java | 147 +++++-- .../repository/jpa/TargetManagementTest.java | 4 +- .../jpa/autoassign/AutoAssignCheckerTest.java | 57 ++- .../rsql/RSQLTargetFilterQueryFieldsTest.java | 109 ++++++ .../rsql/RSQLTargetMetadataFieldsTest.java | 2 +- .../MgmtDistributionSetAutoAssignment.java | 32 ++ .../targetfilter/MgmtTargetFilterQuery.java | 12 + .../api/MgmtTargetFilterQueryRestApi.java | 9 +- .../resource/MgmtTargetFilterQueryMapper.java | 1 + .../MgmtTargetFilterQueryResource.java | 8 +- .../MgmtTargetFilterQueryResourceTest.java | 152 +++++++- .../rest/resource/MgmtTargetResourceTest.java | 12 +- .../exception/ResponseExceptionHandler.java | 3 +- .../DistributionSetsDocumentationTest.java | 12 +- ...ilterQueriesResourceDocumentationTest.java | 21 +- .../common/AbstractMetadataPopupLayout.java | 19 +- .../hawkbit/ui/common/CommonDialogWindow.java | 16 +- .../TargetMetadataDetailsLayout.java | 2 +- .../ui/components/ProxyTargetFilter.java | 15 +- .../dstable/ManageDistBeanQuery.java | 73 ++-- .../DistributionSetSelectComboBox.java | 336 ++++++++++++++++ .../DistributionSetSelectTable.java | 158 -------- .../DistributionSetSelectWindow.java | 176 ++++----- .../FilterManagementView.java | 10 +- .../TargetFilterBeanQuery.java | 1 + .../filtermanagement/TargetFilterTable.java | 34 +- .../management/dstable/DistributionTable.java | 16 +- .../AbstractActionTypeOptionGroupLayout.java | 124 ++++++ ...ctionTypeOptionGroupAssignmentLayout.java} | 85 +--- ...onTypeOptionGroupAutoAssignmentLayout.java | 40 ++ .../TargetMetadataPopupLayout.java | 2 +- .../management/targettable/TargetTable.java | 16 +- .../rollout/AddUpdateRolloutWindowLayout.java | 15 +- .../ui/utils/SPUILabelDefinitions.java | 4 + .../ui/utils/UIComponentIdProvider.java | 4 +- .../hawkbit/ui/utils/UIMessageIdProvider.java | 20 + .../src/main/resources/messages.properties | 3 +- 63 files changed, 1891 insertions(+), 750 deletions(-) create mode 100644 docs/static/images/ui/target_filter_auto_assignment.png create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignActionTypeException.java create mode 100644 hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignDistributionSetException.java create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_11__add_auto_assign_action_type___DB2.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_11__add_auto_assign_action_type___H2.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_11__add_auto_assign_action_type___MYSQL.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_11__add_auto_assign_action_type___SQL_SERVER.sql create mode 100644 hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFilterQueryFieldsTest.java create mode 100644 hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java create mode 100644 hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectComboBox.java delete mode 100644 hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectTable.java create mode 100644 hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/AbstractActionTypeOptionGroupLayout.java rename hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/{ActionTypeOptionGroupLayout.java => ActionTypeOptionGroupAssignmentLayout.java} (59%) create mode 100644 hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAutoAssignmentLayout.java diff --git a/docs/content/ui.md b/docs/content/ui.md index e5cf6605b..63fa86ab1 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -108,6 +108,7 @@ Custom target filter overview and filter management. - Custom target filter allows user to filter targets by defining custom query. - Displays custom target filter list and user can search any particular filter. - Create, update and delete features are supported for target filters. +- Auto assignment of a distribution set to filtered targets. ### How to Filter The basic syntax to filter is: `fieldvalue fieldvalue <...>` @@ -139,3 +140,12 @@ name==CCU* and updatestatus==pending (updatestatus!=error or updatestatus!=pending) and (name==\*CCU\* or description==\*CCU\*) | Gives all targets that either have the term ‘CCU’ in their name or their description and that either have the _update status_ not in state error or pending. ![Target Filter Management view](../images/ui/target_filter.png) + +### Auto assignment +It is possible to assign some distribution set with different action types (_forced_ or _soft_) to all targets that belong to the corresponding custom target filter, including the ones, that are registered later on. + +In order to activate the auto-assignment, one should first click on _Auto assignment_ cell in Custom Filters table, and then check the corresponding checkbox. After that, the action type and distribution set for auto-assignment should be selected and confirmed. + +As long as the auto-assignment stays active, the scheduler will try to assign selected distribution set to corresponding custom filter targets, that have never seen it before. + +![Auto assignment](../images/ui/target_filter_auto_assignment.png) \ No newline at end of file diff --git a/docs/static/images/ui/target_filter_auto_assignment.png b/docs/static/images/ui/target_filter_auto_assignment.png new file mode 100644 index 0000000000000000000000000000000000000000..2b195bd4c577055a085a35ef8c78a5bf2865d9f5 GIT binary patch literal 112166 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�Nh6>xXni0 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 C3KQP| literal 0 HcmV?d00001 diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java index 737b6b50f..c7cc34949 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/exception/SpServerError.java @@ -22,82 +22,98 @@ public enum SpServerError { /** * */ - SP_REPO_ENTITY_ALRREADY_EXISTS("hawkbit.server.error.repo.entitiyAlreayExists", "The given entity already exists in database"), + SP_REPO_ENTITY_ALRREADY_EXISTS("hawkbit.server.error.repo.entitiyAlreayExists", + "The given entity already exists in database"), /** * */ - SP_REPO_CONSTRAINT_VIOLATION("hawkbit.server.error.repo.constraintViolation", "The given entity cannot be saved due to Constraint Violation"), + SP_REPO_CONSTRAINT_VIOLATION("hawkbit.server.error.repo.constraintViolation", + "The given entity cannot be saved due to Constraint Violation"), /** * */ - SP_REPO_INVALID_TARGET_ADDRESS("hawkbit.server.error.repo.invalidTargetAddress", "The target address is not well formed"), + SP_REPO_INVALID_TARGET_ADDRESS("hawkbit.server.error.repo.invalidTargetAddress", + "The target address is not well formed"), /** * */ - SP_REPO_ENTITY_NOT_EXISTS("hawkbit.server.error.repo.entitiyNotFound", "The given entity does not exist in the repository"), + SP_REPO_ENTITY_NOT_EXISTS("hawkbit.server.error.repo.entitiyNotFound", + "The given entity does not exist in the repository"), /** * */ - SP_REPO_CONCURRENT_MODIFICATION("hawkbit.server.error.repo.concurrentModification", "The given entity has been changed by another user/session"), + SP_REPO_CONCURRENT_MODIFICATION("hawkbit.server.error.repo.concurrentModification", + "The given entity has been changed by another user/session"), /** * */ - SP_TARGET_ATTRIBUTES_INVALID("hawkbit.server.error.repo.invalidTargetAttributes", "The given target attributes are invalid"), + SP_TARGET_ATTRIBUTES_INVALID("hawkbit.server.error.repo.invalidTargetAttributes", + "The given target attributes are invalid"), /** * */ - SP_REST_SORT_PARAM_SYNTAX("hawkbit.server.error.rest.param.sortParamSyntax", "The given sort paramter is not well formed"), + SP_REST_SORT_PARAM_SYNTAX("hawkbit.server.error.rest.param.sortParamSyntax", + "The given sort paramter is not well formed"), /** * */ - SP_REST_RSQL_SEARCH_PARAM_SYNTAX("hawkbit.server.error.rest.param.rsqlParamSyntax", "The given search paramter is not well formed"), + SP_REST_RSQL_SEARCH_PARAM_SYNTAX("hawkbit.server.error.rest.param.rsqlParamSyntax", + "The given search paramter is not well formed"), /** * */ - SP_REST_RSQL_PARAM_INVALID_FIELD("hawkbit.server.error.rest.param.rsqlInvalidField", "The given search parameter field does not exist"), + SP_REST_RSQL_PARAM_INVALID_FIELD("hawkbit.server.error.rest.param.rsqlInvalidField", + "The given search parameter field does not exist"), /** * */ - SP_REST_SORT_PARAM_INVALID_FIELD("hawkbit.server.error.rest.param.invalidField", "The given sort parameter field does not exist"), + SP_REST_SORT_PARAM_INVALID_FIELD("hawkbit.server.error.rest.param.invalidField", + "The given sort parameter field does not exist"), /** * */ - SP_REST_SORT_PARAM_INVALID_DIRECTION("hawkbit.server.error.rest.param.invalidDirection", "The given sort parameter direction does not exist"), + SP_REST_SORT_PARAM_INVALID_DIRECTION("hawkbit.server.error.rest.param.invalidDirection", + "The given sort parameter direction does not exist"), /** * */ - SP_REST_BODY_NOT_READABLE("hawkbit.server.error.rest.body.notReadable", "The given request body is not well formed"), + SP_REST_BODY_NOT_READABLE("hawkbit.server.error.rest.body.notReadable", + "The given request body is not well formed"), /** * */ - SP_ARTIFACT_UPLOAD_FAILED("hawkbit.server.error.artifact.uploadFailed", "Upload of artifact failed with internal server error."), + SP_ARTIFACT_UPLOAD_FAILED("hawkbit.server.error.artifact.uploadFailed", + "Upload of artifact failed with internal server error."), /** * */ - SP_ARTIFACT_UPLOAD_FAILED_MD5_MATCH("hawkbit.server.error.artifact.uploadFailed.checksum.md5.match", "Upload of artifact failed as the provided MD5 checksum did not match with the provided artifact."), + SP_ARTIFACT_UPLOAD_FAILED_MD5_MATCH("hawkbit.server.error.artifact.uploadFailed.checksum.md5.match", + "Upload of artifact failed as the provided MD5 checksum did not match with the provided artifact."), /** * */ - SP_ARTIFACT_UPLOAD_FAILED_SHA1_MATCH("hawkbit.server.error.artifact.uploadFailed.checksum.sha1.match", "Upload of artifact failed as the provided SHA1 checksum did not match with the provided artifact."), + SP_ARTIFACT_UPLOAD_FAILED_SHA1_MATCH("hawkbit.server.error.artifact.uploadFailed.checksum.sha1.match", + "Upload of artifact failed as the provided SHA1 checksum did not match with the provided artifact."), /** * */ - SP_DS_CREATION_FAILED_MISSING_MODULE("hawkbit.server.error.distributionset.creationFailed.missingModule", "Creation if Distribution Set failed as module is missing that is configured as mandatory."), + SP_DS_CREATION_FAILED_MISSING_MODULE("hawkbit.server.error.distributionset.creationFailed.missingModule", + "Creation if Distribution Set failed as module is missing that is configured as mandatory."), /** * @@ -107,12 +123,14 @@ public enum SpServerError { /** * */ - SP_ARTIFACT_DELETE_FAILED("hawkbit.server.error.artifact.deleteFailed", "Deletion of artifact failed with internal server error."), + SP_ARTIFACT_DELETE_FAILED("hawkbit.server.error.artifact.deleteFailed", + "Deletion of artifact failed with internal server error."), /** * */ - SP_ARTIFACT_LOAD_FAILED("hawkbit.server.error.artifact.loadFailed", "Load of artifact failed with internal server error."), + SP_ARTIFACT_LOAD_FAILED("hawkbit.server.error.artifact.loadFailed", + "Load of artifact failed with internal server error."), /** * @@ -123,33 +141,39 @@ public enum SpServerError { * error message, which describes that the action can not be canceled cause * the action is inactive. */ - SP_ACTION_NOT_CANCELABLE("hawkbit.server.error.action.notcancelable", "Only active actions which are in status pending are canceable."), + SP_ACTION_NOT_CANCELABLE("hawkbit.server.error.action.notcancelable", + "Only active actions which are in status pending are canceable."), /** * error message, which describes that the action can not be force quit * cause the action is inactive. */ - SP_ACTION_NOT_FORCE_QUITABLE("hawkbit.server.error.action.notforcequitable", "Only active actions which are in status pending can be force quit."), + SP_ACTION_NOT_FORCE_QUITABLE("hawkbit.server.error.action.notforcequitable", + "Only active actions which are in status pending can be force quit."), /** * */ - SP_DS_INCOMPLETE("hawkbit.server.error.distributionset.incomplete", "Distribution set is assigned to a a target that is incomplete (i.e. mandatory modules are missing)"), + SP_DS_INCOMPLETE("hawkbit.server.error.distributionset.incomplete", + "Distribution set is assigned to a target that is incomplete (i.e. mandatory modules are missing)"), /** * */ - SP_DS_TYPE_UNDEFINED("hawkbit.server.error.distributionset.type.undefined", "Distribution set type is not yet defined. Modules cannot be added until definition."), + SP_DS_TYPE_UNDEFINED("hawkbit.server.error.distributionset.type.undefined", + "Distribution set type is not yet defined. Modules cannot be added until definition."), /** * */ - SP_DS_MODULE_UNSUPPORTED("hawkbit.server.error.distributionset.modules.unsupported", "Distribution set type does not contain the given module, i.e. is incompatible."), + SP_DS_MODULE_UNSUPPORTED("hawkbit.server.error.distributionset.modules.unsupported", + "Distribution set type does not contain the given module, i.e. is incompatible."), /** * */ - SP_REPO_TENANT_NOT_EXISTS("hawkbit.server.error.repo.tenantNotExists", "The entity cannot be inserted due the tenant does not exists"), + SP_REPO_TENANT_NOT_EXISTS("hawkbit.server.error.repo.tenantNotExists", + "The entity cannot be inserted due the tenant does not exists"), /** * @@ -159,12 +183,14 @@ public enum SpServerError { /** * */ - SP_REPO_ENTITY_READ_ONLY("hawkbit.server.error.entityreadonly", "The given entity is read only and the change cannot be completed."), + SP_REPO_ENTITY_READ_ONLY("hawkbit.server.error.entityreadonly", + "The given entity is read only and the change cannot be completed."), /** * */ - SP_CONFIGURATION_VALUE_INVALID("hawkbit.server.error.configValueInvalid", "The given configuration value is invalid."), + SP_CONFIGURATION_VALUE_INVALID("hawkbit.server.error.configValueInvalid", + "The given configuration value is invalid."), /** * */ @@ -173,22 +199,40 @@ public enum SpServerError { /** * */ - SP_ROLLOUT_ILLEGAL_STATE("hawkbit.server.error.rollout.illegalstate", "The rollout is in the wrong state for the requested operation"), + SP_ROLLOUT_ILLEGAL_STATE("hawkbit.server.error.rollout.illegalstate", + "The rollout is in the wrong state for the requested operation"), /** * */ - SP_ROLLOUT_VERIFICATION_FAILED("hawkbit.server.error.rollout.verificationFailed", "The rollout configuration could not be verified successfully"), + SP_ROLLOUT_VERIFICATION_FAILED("hawkbit.server.error.rollout.verificationFailed", + "The rollout configuration could not be verified successfully"), /** * */ - SP_REPO_OPERATION_NOT_SUPPORTED("hawkbit.server.error.operation.notSupported", "Operation or method is (no longer) supported by service."), + SP_REPO_OPERATION_NOT_SUPPORTED("hawkbit.server.error.operation.notSupported", + "Operation or method is (no longer) supported by service."), /** * Error message informing that the maintenance schedule is invalid. */ - SP_MAINTENANCE_SCHEDULE_INVALID("hawkbit.server.error.maintenanceScheduleInvalid", "Information for schedule, duration or timezone is missing; or there is no valid maintenance window available in future."); + SP_MAINTENANCE_SCHEDULE_INVALID("hawkbit.server.error.maintenanceScheduleInvalid", + "Information for schedule, duration or timezone is missing; or there is no valid maintenance window available in future."), + + /** + * Error message informing that the action type for auto-assignment is + * invalid. + */ + SP_AUTO_ASSIGN_ACTION_TYPE_INVALID("hawkbit.server.error.repo.invalidAutoAssignActionType", + "The given action type for auto-assignment is invalid: allowed values are FORCED and SOFT"), + + /** + * Error message informing that the distribution set for auto-assignment is + * invalid. + */ + SP_AUTO_ASSIGN_DISTRIBUTION_SET_INVALID("hawkbit.server.error.repo.invalidAutoAssignDistributionSet", + "The given distribution set for auto-assignment is invalid: it is either incomplete (i.e. mandatory modules are missing) or soft deleted"); private final String key; private final String message; diff --git a/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryFields.java b/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryFields.java index 94c02fd83..7378589a2 100644 --- a/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryFields.java +++ b/hawkbit-core/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryFields.java @@ -30,7 +30,7 @@ public enum TargetFilterQueryFields implements FieldNameProvider { NAME("name"), /** - * distribution set which is set as auto assign distribution set + * Distribution set for auto-assignment. */ AUTOASSIGNDISTRIBUTIONSET("autoAssignDistributionSet", "name", "version"); diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryManagement.java index f5a3a53c6..a210703a1 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/RepositoryManagement.java @@ -23,7 +23,6 @@ import org.eclipse.hawkbit.repository.exception.EntityReadOnlyException; import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; import org.eclipse.hawkbit.repository.model.BaseEntity; -import org.eclipse.hawkbit.repository.model.SoftwareModuleType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -56,7 +55,7 @@ public interface RepositoryManagement { List create(@NotNull @Valid Collection creates); /** - * Creates new {@link SoftwareModuleType}. + * Creates new {@link BaseEntity}. * * @param create * to create diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryManagement.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryManagement.java index dee780e33..d23c2eea9 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryManagement.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/TargetFilterQueryManagement.java @@ -18,9 +18,12 @@ import org.eclipse.hawkbit.im.authentication.SpPermission.SpringEvalExpressions; import org.eclipse.hawkbit.repository.builder.TargetFilterQueryCreate; import org.eclipse.hawkbit.repository.builder.TargetFilterQueryUpdate; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignActionTypeException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignDistributionSetException; import org.eclipse.hawkbit.repository.exception.QuotaExceededException; import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.springframework.data.domain.Page; @@ -213,13 +216,14 @@ public interface TargetFilterQueryManagement { TargetFilterQuery update(@NotNull @Valid TargetFilterQueryUpdate update); /** - * Updates the the auto-assign {@link DistributionSet} of the addressed - * {@link TargetFilterQuery}. + * Updates the the auto-assign {@link DistributionSet} and sets default + * (FORCED) {@link ActionType} of the addressed {@link TargetFilterQuery}. * * @param queryId * of the target filter query to be updated * @param dsId * to be updated or null in order to remove it + * together with the auto-assign {@link ActionType} * * @return the updated {@link TargetFilterQuery} * @@ -230,8 +234,47 @@ public interface TargetFilterQueryManagement { * @throws QuotaExceededException * if the query that is already associated with this filter * query addresses too many targets (auto-assignments only) + * + * @throws InvalidAutoAssignDistributionSetException + * if the provided auto-assign {@link DistributionSet} is not + * valid (incomplete or soft deleted) */ @PreAuthorize(SpringEvalExpressions.HAS_AUTH_UPDATE_TARGET) - TargetFilterQuery updateAutoAssignDS(long queryId, Long dsId); + default TargetFilterQuery updateAutoAssignDS(final long queryId, final Long dsId) { + return updateAutoAssignDSWithActionType(queryId, dsId, null); + } + /** + * Updates the the auto-assign {@link DistributionSet} and + * {@link ActionType} of the addressed {@link TargetFilterQuery}. + * + * @param queryId + * of the target filter query to be updated + * @param dsId + * to be updated or null in order to remove it + * together with the auto-assign {@link ActionType} + * @param actionType + * to be updated or null for default (FORCED) if + * distribution set Id is present + * + * @return the updated {@link TargetFilterQuery} + * + * @throws EntityNotFoundException + * if either {@link TargetFilterQuery} and/or autoAssignDs are + * provided but not found + * + * @throws QuotaExceededException + * if the query that is already associated with this filter + * query addresses too many targets (auto-assignments only) + * + * @throws InvalidAutoAssignActionTypeException + * if the provided auto-assign {@link ActionType} is not valid + * (neither FORCED, nor SOFT) + * + * @throws InvalidAutoAssignDistributionSetException + * if the provided auto-assign {@link DistributionSet} is not + * valid (incomplete or soft deleted) + */ + @PreAuthorize(SpringEvalExpressions.HAS_AUTH_UPDATE_TARGET) + TargetFilterQuery updateAutoAssignDSWithActionType(long queryId, Long dsId, ActionType actionType); } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java index 93527350e..187bfbf14 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/builder/TargetFilterQueryCreate.java @@ -13,6 +13,7 @@ import java.util.Optional; import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.BaseEntity; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.NamedEntity; @@ -40,20 +41,27 @@ public interface TargetFilterQueryCreate { TargetFilterQueryCreate query(@Size(min = 1, max = TargetFilterQuery.QUERY_MAX_SIZE) @NotNull String query); /** - * @param set + * @param distributionSet * for {@link TargetFilterQuery#getAutoAssignDistributionSet()} * @return updated builder instance */ - default TargetFilterQueryCreate set(final DistributionSet set) { - return set(Optional.ofNullable(set).map(DistributionSet::getId).orElse(null)); + default TargetFilterQueryCreate autoAssignDistributionSet(final DistributionSet distributionSet) { + return autoAssignDistributionSet(Optional.ofNullable(distributionSet).map(DistributionSet::getId).orElse(null)); } /** - * @param setId + * @param dsId * for {@link TargetFilterQuery#getAutoAssignDistributionSet()} * @return updated builder instance */ - TargetFilterQueryCreate set(long setId); + TargetFilterQueryCreate autoAssignDistributionSet(Long dsId); + + /** + * @param actionType + * for {@link TargetFilterQuery#getAutoAssignActionType()} + * @return updated builder instance + */ + TargetFilterQueryCreate autoAssignActionType(ActionType actionType); /** * @return peek on current state of {@link TargetFilterQuery} in the builder diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/EntityNotFoundException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/EntityNotFoundException.java index 6c9bdbbb8..c292a6576 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/EntityNotFoundException.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/EntityNotFoundException.java @@ -88,7 +88,7 @@ public class EntityNotFoundException extends AbstractServerRtException { * for the {@link MetaData} entry */ public EntityNotFoundException(final Class type, final Long entityId, final String key) { - this(type.getSimpleName() + " for given entity {" + entityId + "} and with key {" + key + "} does not exist."); + this(type, String.valueOf(entityId), key); } /** @@ -96,13 +96,13 @@ public class EntityNotFoundException extends AbstractServerRtException { * * @param type * of the entity that was not found - * @param enityId + * @param entityId * of the {@link BaseEntity} the {@link MetaData} was for * @param key * for the {@link MetaData} entry */ - public EntityNotFoundException(final Class type, final String enityId, final String key) { - this(type.getSimpleName() + " for given entity {" + enityId + "} and with key {" + key + "} does not exist."); + public EntityNotFoundException(final Class type, final String entityId, final String key) { + this(type.getSimpleName() + " for given entity {" + entityId + "} and with key {" + key + "} does not exist."); } /** diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignActionTypeException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignActionTypeException.java new file mode 100644 index 000000000..6db9b901b --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignActionTypeException.java @@ -0,0 +1,29 @@ +/** + * Copyright (c) 2018 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.repository.exception; + +import org.eclipse.hawkbit.exception.AbstractServerRtException; +import org.eclipse.hawkbit.exception.SpServerError; + +/** + * Thrown if an action type for auto-assignment is neither 'forced', nor 'soft'. + */ +public class InvalidAutoAssignActionTypeException extends AbstractServerRtException { + + private static final long serialVersionUID = 1L; + private static final SpServerError THIS_ERROR = SpServerError.SP_AUTO_ASSIGN_ACTION_TYPE_INVALID; + + /** + * Default constructor. + */ + public InvalidAutoAssignActionTypeException() { + super(THIS_ERROR); + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignDistributionSetException.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignDistributionSetException.java new file mode 100644 index 000000000..2c798d286 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/exception/InvalidAutoAssignDistributionSetException.java @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2019 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ + +package org.eclipse.hawkbit.repository.exception; + +import org.eclipse.hawkbit.exception.AbstractServerRtException; +import org.eclipse.hawkbit.exception.SpServerError; + +/** + * Thrown if a distribution set for auto-assignment is incomplete (i.e. + * mandatory modules are missing) or soft deleted. + */ +public class InvalidAutoAssignDistributionSetException extends AbstractServerRtException { + + private static final long serialVersionUID = 1L; + private static final SpServerError THIS_ERROR = SpServerError.SP_AUTO_ASSIGN_DISTRIBUTION_SET_INVALID; + + /** + * Default constructor. + */ + public InvalidAutoAssignDistributionSetException() { + super(THIS_ERROR); + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DistributionSetFilter.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DistributionSetFilter.java index da8c68347..27565c305 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DistributionSetFilter.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/DistributionSetFilter.java @@ -22,6 +22,7 @@ public final class DistributionSetFilter { private Boolean isComplete; private DistributionSetType type; private String searchText; + private String filterString; private Boolean selectDSWithNoTag; private Collection tagNames; private String assignedTargetId; @@ -61,6 +62,11 @@ public final class DistributionSetFilter { return this; } + public DistributionSetFilterBuilder setFilterString(final String filterString) { + this.filterString = filterString; + return this; + } + public DistributionSetFilterBuilder setSelectDSWithNoTag(final Boolean selectDSWithNoTag) { this.selectDSWithNoTag = selectDSWithNoTag; return this; @@ -82,6 +88,7 @@ public final class DistributionSetFilter { private final Boolean isComplete; private final DistributionSetType type; private final String searchText; + private final String filterString; private final Boolean selectDSWithNoTag; private final Collection tagNames; private final String assignedTargetId; @@ -99,6 +106,7 @@ public final class DistributionSetFilter { this.isComplete = builder.isComplete; this.type = builder.type; this.searchText = builder.searchText; + this.filterString = builder.filterString; this.selectDSWithNoTag = builder.selectDSWithNoTag; this.tagNames = builder.tagNames; this.assignedTargetId = builder.assignedTargetId; @@ -125,6 +133,10 @@ public final class DistributionSetFilter { return searchText; } + public String getFilterString() { + return filterString; + } + public Boolean getSelectDSWithNoTag() { return selectDSWithNoTag; } diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java index 931031603..d8f7ab2ca 100644 --- a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/model/TargetFilterQuery.java @@ -8,6 +8,12 @@ */ package org.eclipse.hawkbit.repository.model; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +import org.eclipse.hawkbit.repository.model.Action.ActionType; + /** * Managed filter entity. * @@ -38,6 +44,12 @@ public interface TargetFilterQuery extends TenantAwareBaseEntity { */ int QUERY_MAX_SIZE = 1024; + /** + * Allowed values for auto-assign action type + */ + Set ALLOWED_AUTO_ASSIGN_ACTION_TYPES = Collections + .unmodifiableSet(EnumSet.of(ActionType.FORCED, ActionType.SOFT)); + /** * @return name of the {@link TargetFilterQuery}. */ @@ -53,4 +65,9 @@ public interface TargetFilterQuery extends TenantAwareBaseEntity { */ DistributionSet getAutoAssignDistributionSet(); + /** + * @return the auto assign {@link ActionType} if given. + */ + ActionType getAutoAssignActionType(); + } diff --git a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java index 1b4938901..c7693e464 100644 --- a/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java +++ b/hawkbit-repository/hawkbit-repository-core/src/main/java/org/eclipse/hawkbit/repository/builder/AbstractTargetFilterQueryUpdateCreate.java @@ -11,6 +11,7 @@ package org.eclipse.hawkbit.repository.builder; import java.util.Optional; import org.eclipse.hawkbit.repository.ValidString; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.springframework.util.StringUtils; /** @@ -26,15 +27,26 @@ public abstract class AbstractTargetFilterQueryUpdateCreate extends AbstractB @ValidString protected String query; - protected Long set; + protected Long distributionSetId; - public T set(final long set) { - this.set = set; + protected ActionType actionType; + + public T autoAssignDistributionSet(final Long distributionSetId) { + this.distributionSetId = distributionSetId; return (T) this; } - public Optional getSet() { - return Optional.ofNullable(set); + public Optional getAutoAssignDistributionSetId() { + return Optional.ofNullable(distributionSetId); + } + + public T autoAssignActionType(final ActionType actionType) { + this.actionType = actionType; + return (T) this; + } + + public Optional getAutoAssignActionType() { + return Optional.ofNullable(actionType); } public T name(final String name) { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDistributionSetManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDistributionSetManagement.java index 76a96efa8..d89e85d9a 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDistributionSetManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaDistributionSetManagement.java @@ -267,7 +267,7 @@ public class JpaDistributionSetManagement implements DistributionSetManagement { if (!assigned.isEmpty()) { final Long[] dsIds = assigned.toArray(new Long[assigned.size()]); distributionSetRepository.deleteDistributionSet(dsIds); - targetFilterQueryRepository.unsetAutoAssignDistributionSet(dsIds); + targetFilterQueryRepository.unsetAutoAssignDistributionSetAndActionType(dsIds); } // mark the rest as hard delete @@ -276,6 +276,8 @@ public class JpaDistributionSetManagement implements DistributionSetManagement { // hard delete the rest if exists if (!toHardDelete.isEmpty()) { + targetFilterQueryRepository + .unsetAutoAssignDistributionSetAndActionType(toHardDelete.toArray(new Long[toHardDelete.size()])); // don't give the delete statement an empty list, JPA/Oracle cannot // handle the empty list distributionSetRepository.deleteByIdIn(toHardDelete); @@ -602,7 +604,7 @@ public class JpaDistributionSetManagement implements DistributionSetManagement { private static List> buildDistributionSetSpecifications( final DistributionSetFilter distributionSetFilter) { - final List> specList = Lists.newArrayListWithExpectedSize(7); + final List> specList = Lists.newArrayListWithExpectedSize(8); Specification spec; @@ -626,6 +628,14 @@ public class JpaDistributionSetManagement implements DistributionSetManagement { specList.add(spec); } + if (!StringUtils.isEmpty(distributionSetFilter.getFilterString())) { + final String[] dsFilterNameAndVersionEntries = getDsFilterNameAndVersionEntries( + distributionSetFilter.getFilterString().trim()); + spec = DistributionSetSpecification.likeNameAndVersion(dsFilterNameAndVersionEntries[0], + dsFilterNameAndVersionEntries[1]); + specList.add(spec); + } + if (isDSWithNoTagSelected(distributionSetFilter) || isTagsSelected(distributionSetFilter)) { spec = DistributionSetSpecification.hasTags(distributionSetFilter.getTagNames(), distributionSetFilter.getSelectDSWithNoTag()); @@ -642,6 +652,19 @@ public class JpaDistributionSetManagement implements DistributionSetManagement { return specList; } + // the format of filter string is 'name:version'. 'name' and 'version' + // fields follow the starts_with semantic, that changes to equal for 'name' + // field when the semicolon is present + private static String[] getDsFilterNameAndVersionEntries(final String filterString) { + final int semicolonIndex = filterString.indexOf(':'); + + final String dsFilterName = semicolonIndex != -1 ? filterString.substring(0, semicolonIndex) + : (filterString + "%"); + final String dsFilterVersion = semicolonIndex != -1 ? (filterString.substring(semicolonIndex + 1) + "%") : "%"; + + return new String[] { !StringUtils.isEmpty(dsFilterName) ? dsFilterName : "%", dsFilterVersion }; + } + private void assertDistributionSetIsNotAssignedToTargets(final Long distributionSet) { if (actionRepository.countByDistributionSetId(distributionSet) > 0) { throw new EntityReadOnlyException(String.format( diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java index 7a11ed34e..c3b767077 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/JpaTargetFilterQueryManagement.java @@ -22,6 +22,8 @@ import org.eclipse.hawkbit.repository.builder.GenericTargetFilterQueryUpdate; import org.eclipse.hawkbit.repository.builder.TargetFilterQueryCreate; import org.eclipse.hawkbit.repository.builder.TargetFilterQueryUpdate; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignActionTypeException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignDistributionSetException; import org.eclipse.hawkbit.repository.jpa.builder.JpaTargetFilterQueryCreate; import org.eclipse.hawkbit.repository.jpa.configuration.Constants; import org.eclipse.hawkbit.repository.jpa.model.JpaDistributionSet; @@ -30,6 +32,7 @@ import org.eclipse.hawkbit.repository.jpa.rsql.RSQLUtility; import org.eclipse.hawkbit.repository.jpa.specifications.SpecificationsBuilder; import org.eclipse.hawkbit.repository.jpa.specifications.TargetFilterQuerySpecification; import org.eclipse.hawkbit.repository.jpa.utils.QuotaHelper; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; @@ -88,7 +91,9 @@ public class JpaTargetFilterQueryManagement implements TargetFilterQueryManageme // enforce the 'max targets per auto assign' quota right here even if // the result of the filter query can vary over time - create.getSet().flatMap(set -> create.getQuery()).ifPresent(this::assertMaxTargetsQuota); + if (create.getAutoAssignDistributionSetId().isPresent()) { + create.getQuery().ifPresent(this::assertMaxTargetsQuota); + } return targetFilterQueryRepository.save(create.build()); } @@ -217,24 +222,53 @@ public class JpaTargetFilterQueryManagement implements TargetFilterQueryManageme @Override @Transactional - public TargetFilterQuery updateAutoAssignDS(final long queryId, final Long dsId) { + public TargetFilterQuery updateAutoAssignDSWithActionType(final long queryId, final Long dsId, + final ActionType actionType) { final JpaTargetFilterQuery targetFilterQuery = findTargetFilterQueryOrThrowExceptionIfNotFound(queryId); - targetFilterQuery.setAutoAssignDistributionSet( - Optional.ofNullable(dsId).map(this::findDistributionSetAndThrowExceptionIfNotFound).orElse(null)); - - // we cannot be sure that the quota was enforced at creation time - // because the Target Filter Query REST API does not allow to specify an - // auto-assign distribution set when creating a target filter query - if (dsId != null) { + if (dsId == null) { + targetFilterQuery.setAutoAssignDistributionSet(null); + targetFilterQuery.setAutoAssignActionType(null); + } else { + // we cannot be sure that the quota was enforced at creation time + // because the Target Filter Query REST API does not allow to + // specify an + // auto-assign distribution set when creating a target filter query assertMaxTargetsQuota(targetFilterQuery.getQuery()); + + final JpaDistributionSet distributionSetToAutoAssign = findDistributionSetAndThrowExceptionIfNotFound(dsId); + // must be completed and not soft deleted + verifyDistributionSetAndThrowExceptionIfNotValid(distributionSetToAutoAssign); + + targetFilterQuery.setAutoAssignDistributionSet(distributionSetToAutoAssign); + // the action type is set to FORCED per default (when not explicitly + // specified) + targetFilterQuery.setAutoAssignActionType(sanitizeAutoAssignActionType(actionType)); } return targetFilterQueryRepository.save(targetFilterQuery); } + private static void verifyDistributionSetAndThrowExceptionIfNotValid(final DistributionSet distributionSet) { + if (!distributionSet.isComplete() || distributionSet.isDeleted()) { + throw new InvalidAutoAssignDistributionSetException(); + } + } + + private static ActionType sanitizeAutoAssignActionType(final ActionType actionType) { + if (actionType == null) { + return ActionType.FORCED; + } + + if (!TargetFilterQuery.ALLOWED_AUTO_ASSIGN_ACTION_TYPES.contains(actionType)) { + throw new InvalidAutoAssignActionTypeException(); + } + + return actionType; + } + private JpaDistributionSet findDistributionSetAndThrowExceptionIfNotFound(final Long setId) { - return (JpaDistributionSet) distributionSetManagement.getWithDetails(setId) + return (JpaDistributionSet) distributionSetManagement.get(setId) .orElseThrow(() -> new EntityNotFoundException(DistributionSet.class, setId)); } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryRepository.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryRepository.java index ab05474ad..cc3d70895 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryRepository.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryRepository.java @@ -45,15 +45,16 @@ public interface TargetFilterQueryRepository Page findAll(); /** - * Sets the auto assign distribution sets to null which match the ds ids. + * Sets the auto assign distribution sets and action types to null which + * match the ds ids. * * @param dsIds * distribution set ids to be set to null */ @Modifying @Transactional - @Query("update JpaTargetFilterQuery d set d.autoAssignDistributionSet = NULL where d.autoAssignDistributionSet in :ids") - void unsetAutoAssignDistributionSet(@Param("ids") Long... dsIds); + @Query("update JpaTargetFilterQuery d set d.autoAssignDistributionSet = NULL, d.autoAssignActionType = NULL where d.autoAssignDistributionSet in :ids") + void unsetAutoAssignDistributionSetAndActionType(@Param("ids") Long... dsIds); /** * Deletes all {@link TenantAwareBaseEntity} of a given tenant. For safety diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignChecker.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignChecker.java index a0c1c080e..6f98f0460 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignChecker.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignChecker.java @@ -17,7 +17,8 @@ import org.eclipse.hawkbit.exception.AbstractServerRtException; import org.eclipse.hawkbit.repository.DeploymentManagement; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; import org.eclipse.hawkbit.repository.TargetManagement; -import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.jpa.utils.DeploymentHelper; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.RepositoryModelConstants; import org.eclipse.hawkbit.repository.model.Target; @@ -28,12 +29,9 @@ import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.DefaultTransactionDefinition; -import org.springframework.transaction.support.TransactionTemplate; /** * Checks if targets need a new distribution set (DS) based on the target filter @@ -52,7 +50,7 @@ public class AutoAssignChecker { private final DeploymentManagement deploymentManagement; - private final TransactionTemplate transactionTemplate; + private final PlatformTransactionManager transactionManager; /** * Maximum for target filter queries with auto assign DS Maximum for targets @@ -84,13 +82,7 @@ public class AutoAssignChecker { this.targetFilterQueryManagement = targetFilterQueryManagement; this.targetManagement = targetManagement; this.deploymentManagement = deploymentManagement; - - final DefaultTransactionDefinition def = new DefaultTransactionDefinition(); - def.setName("autoAssignDSToTargets"); - def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); - def.setReadOnly(false); - def.setIsolationLevel(Isolation.READ_COMMITTED.value()); - transactionTemplate = new TransactionTemplate(transactionManager, def); + this.transactionManager = transactionManager; } /** @@ -104,8 +96,7 @@ public class AutoAssignChecker { final PageRequest pageRequest = PageRequest.of(0, PAGE_SIZE); - final Page filterQueries = targetFilterQueryManagement - .findWithAutoAssignDS(pageRequest); + final Page filterQueries = targetFilterQueryManagement.findWithAutoAssignDS(pageRequest); for (final TargetFilterQuery filterQuery : filterQueries) { checkByTargetFilterQueryAndAssignDS(filterQuery); @@ -149,15 +140,17 @@ public class AutoAssignChecker { */ private int runTransactionalAssignment(final TargetFilterQuery targetFilterQuery, final Long dsId) { final String actionMessage = String.format(ACTION_MESSAGE, targetFilterQuery.getName()); - return transactionTemplate.execute(status -> { - final List targets = getTargetsWithActionType(targetFilterQuery.getQuery(), dsId, - PAGE_SIZE); - final int count = targets.size(); - if (count > 0) { - deploymentManagement.assignDistributionSet(dsId, targets, actionMessage); - } - return count; - }); + + return DeploymentHelper.runInNewTransaction(transactionManager, "autoAssignDSToTargets", + Isolation.READ_COMMITTED.value(), status -> { + final List targets = getTargetsWithActionType(targetFilterQuery.getQuery(), + dsId, targetFilterQuery.getAutoAssignActionType(), PAGE_SIZE); + final int count = targets.size(); + if (count > 0) { + deploymentManagement.assignDistributionSet(dsId, targets, actionMessage); + } + return count; + }); } /** @@ -169,17 +162,22 @@ public class AutoAssignChecker { * @param dsId * dsId the targets are not allowed to have in their action * history + * @param type + * action type for targets auto assignment * @param count * maximum amount of targets to retrieve * @return list of targets with action type */ private List getTargetsWithActionType(final String targetFilterQuery, final Long dsId, - final int count) { - final Page targets = targetManagement - .findByTargetFilterQueryAndNonDS(PageRequest.of(0, count), dsId, targetFilterQuery); + final ActionType type, final int count) { + final Page targets = targetManagement.findByTargetFilterQueryAndNonDS(PageRequest.of(0, count), dsId, + targetFilterQuery); + // the action type is set to FORCED per default (when not explicitly + // specified) + final ActionType autoAssignActionType = type == null ? ActionType.FORCED : type; return targets.getContent().stream().map(t -> new TargetWithActionType(t.getControllerId(), - Action.ActionType.FORCED, RepositoryModelConstants.NO_FORCE_TIME)).collect(Collectors.toList()); + autoAssignActionType, RepositoryModelConstants.NO_FORCE_TIME)).collect(Collectors.toList()); } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetFilterQueryCreate.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetFilterQueryCreate.java index 96f06379d..163aa6efb 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetFilterQueryCreate.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/builder/JpaTargetFilterQueryCreate.java @@ -12,8 +12,11 @@ import org.eclipse.hawkbit.repository.DistributionSetManagement; import org.eclipse.hawkbit.repository.builder.AbstractTargetFilterQueryUpdateCreate; import org.eclipse.hawkbit.repository.builder.TargetFilterQueryCreate; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignActionTypeException; import org.eclipse.hawkbit.repository.jpa.model.JpaTargetFilterQuery; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.TargetFilterQuery; /** * Create/build implementation. @@ -32,7 +35,8 @@ public class JpaTargetFilterQueryCreate extends AbstractTargetFilterQueryUpdateC public JpaTargetFilterQuery build() { return new JpaTargetFilterQuery(name, query, - getSet().map(this::findDistributionSetAndThrowExceptionIfNotFound).orElse(null)); + getAutoAssignDistributionSetId().map(this::findDistributionSetAndThrowExceptionIfNotFound).orElse(null), + getAutoAssignActionType().filter(JpaTargetFilterQueryCreate::isAutoAssignActionTypeValid).orElse(null)); } private DistributionSet findDistributionSetAndThrowExceptionIfNotFound(final Long setId) { @@ -40,4 +44,12 @@ public class JpaTargetFilterQueryCreate extends AbstractTargetFilterQueryUpdateC .orElseThrow(() -> new EntityNotFoundException(DistributionSet.class, setId)); } + private static boolean isAutoAssignActionTypeValid(final ActionType actionType) { + if (!TargetFilterQuery.ALLOWED_AUTO_ASSIGN_ACTION_TYPES.contains(actionType)) { + throw new InvalidAutoAssignActionTypeException(); + } + + return true; + } + } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java index 6755bf5a6..693247dd0 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/model/JpaTargetFilterQuery.java @@ -23,10 +23,15 @@ import javax.validation.constraints.Size; import org.eclipse.hawkbit.repository.event.remote.TargetFilterQueryDeletedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetFilterQueryCreatedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetFilterQueryUpdatedEvent; +import org.eclipse.hawkbit.repository.model.Action; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.NamedEntity; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.eclipse.hawkbit.repository.model.helper.EventPublisherHolder; +import org.eclipse.persistence.annotations.ConversionValue; +import org.eclipse.persistence.annotations.Convert; +import org.eclipse.persistence.annotations.ObjectTypeConverter; import org.eclipse.persistence.descriptors.DescriptorEvent; /** @@ -41,7 +46,7 @@ import org.eclipse.persistence.descriptors.DescriptorEvent; @SuppressWarnings("squid:S2160") public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity implements TargetFilterQuery, EventAwareEntity { - private static final long serialVersionUID = 7493966984413479089L; + private static final long serialVersionUID = 1L; @Column(name = "name", length = NamedEntity.NAME_MAX_SIZE, nullable = false) @Size(max = NamedEntity.NAME_MAX_SIZE) @@ -57,6 +62,13 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity @JoinColumn(name = "auto_assign_distribution_set", nullable = true, foreignKey = @ForeignKey(value = ConstraintMode.CONSTRAINT, name = "fk_filter_auto_assign_ds")) private JpaDistributionSet autoAssignDistributionSet; + @Column(name = "auto_assign_action_type", nullable = true) + @ObjectTypeConverter(name = "autoAssignActionType", objectType = Action.ActionType.class, dataType = Integer.class, conversionValues = { + @ConversionValue(objectValue = "FORCED", dataValue = "0"), + @ConversionValue(objectValue = "SOFT", dataValue = "1") }) + @Convert("autoAssignActionType") + private ActionType autoAssignActionType; + public JpaTargetFilterQuery() { // Default constructor for JPA. } @@ -70,12 +82,15 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity * of the {@link TargetFilterQuery}. * @param autoAssignDistributionSet * of the {@link TargetFilterQuery}. + * @param autoAssignActionType + * of the {@link TargetFilterQuery}. */ - public JpaTargetFilterQuery(final String name, final String query, - final DistributionSet autoAssignDistributionSet) { + public JpaTargetFilterQuery(final String name, final String query, final DistributionSet autoAssignDistributionSet, + final ActionType autoAssignActionType) { this.name = name; this.query = query; this.autoAssignDistributionSet = (JpaDistributionSet) autoAssignDistributionSet; + this.autoAssignActionType = autoAssignActionType; } @Override @@ -105,6 +120,15 @@ public class JpaTargetFilterQuery extends AbstractJpaTenantAwareBaseEntity this.autoAssignDistributionSet = distributionSet; } + @Override + public ActionType getAutoAssignActionType() { + return autoAssignActionType; + } + + public void setAutoAssignActionType(final ActionType actionType) { + this.autoAssignActionType = actionType; + } + @Override public void fireCreateEvent(final DescriptorEvent descriptorEvent) { EventPublisherHolder.getInstance().getEventPublisher().publishEvent( diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/DistributionSetSpecification.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/DistributionSetSpecification.java index b7a63765d..828554271 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/DistributionSetSpecification.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/specifications/DistributionSetSpecification.java @@ -120,6 +120,22 @@ public final class DistributionSetSpecification { cb.like(cb.lower(targetRoot. get(JpaDistributionSet_.description)), subString.toLowerCase())); } + /** + * {@link Specification} for retrieving {@link DistributionSet}s by "like + * name and like version". + * + * @param name + * to be filtered on + * @param version + * to be filtered on + * @return the {@link DistributionSet} {@link Specification} + */ + public static Specification likeNameAndVersion(final String name, final String version) { + return (targetRoot, query, cb) -> cb.and( + cb.like(cb.lower(targetRoot. get(JpaDistributionSet_.name)), name.toLowerCase()), + cb.like(cb.lower(targetRoot. get(JpaDistributionSet_.version)), version.toLowerCase())); + } + /** * {@link Specification} for retrieving {@link DistributionSet}s by "has at * least one of the given tag names". diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/DeploymentHelper.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/DeploymentHelper.java index cd248f9ca..d2fea0f27 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/DeploymentHelper.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/utils/DeploymentHelper.java @@ -22,6 +22,7 @@ import org.eclipse.hawkbit.repository.model.Action.Status; import org.eclipse.hawkbit.repository.model.TargetUpdateStatus; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.support.DefaultTransactionDefinition; import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionTemplate; @@ -83,10 +84,30 @@ public final class DeploymentHelper { */ public static T runInNewTransaction(@NotNull final PlatformTransactionManager txManager, final String transactionName, @NotNull final TransactionCallback action) { + return runInNewTransaction(txManager, transactionName, Isolation.DEFAULT.value(), action); + } + + /** + * Executes the modifying action in new transaction + * + * @param txManager + * transaction manager interface + * @param transactionName + * the name of the new transaction + * @param isolationLevel + * isolation level of the new transaction + * @param action + * the callback to execute in new tranaction + * + * @return the result of the action + */ + public static T runInNewTransaction(@NotNull final PlatformTransactionManager txManager, + final String transactionName, final int isolationLevel, @NotNull final TransactionCallback action) { final DefaultTransactionDefinition def = new DefaultTransactionDefinition(); def.setName(transactionName); def.setReadOnly(false); def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + def.setIsolationLevel(isolationLevel); return new TransactionTemplate(txManager, def).execute(action); } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_11__add_auto_assign_action_type___DB2.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_11__add_auto_assign_action_type___DB2.sql new file mode 100644 index 000000000..9c6b67bf3 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/DB2/V1_12_11__add_auto_assign_action_type___DB2.sql @@ -0,0 +1 @@ +ALTER TABLE sp_target_filter_query ADD COLUMN auto_assign_action_type INTEGER; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_11__add_auto_assign_action_type___H2.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_11__add_auto_assign_action_type___H2.sql new file mode 100644 index 000000000..16cb03ef2 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/H2/V1_12_11__add_auto_assign_action_type___H2.sql @@ -0,0 +1 @@ +ALTER TABLE sp_target_filter_query ADD COLUMN auto_assign_action_type integer; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_11__add_auto_assign_action_type___MYSQL.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_11__add_auto_assign_action_type___MYSQL.sql new file mode 100644 index 000000000..16cb03ef2 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/MYSQL/V1_12_11__add_auto_assign_action_type___MYSQL.sql @@ -0,0 +1 @@ +ALTER TABLE sp_target_filter_query ADD COLUMN auto_assign_action_type integer; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_11__add_auto_assign_action_type___SQL_SERVER.sql b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_11__add_auto_assign_action_type___SQL_SERVER.sql new file mode 100644 index 000000000..0e8c37bda --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/resources/db/migration/SQL_SERVER/V1_12_11__add_auto_assign_action_type___SQL_SERVER.sql @@ -0,0 +1 @@ +ALTER TABLE sp_target_filter_query ADD auto_assign_action_type INTEGER; \ No newline at end of file diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DistributionSetManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DistributionSetManagementTest.java index 26807130b..cbd2d5f7e 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DistributionSetManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/DistributionSetManagementTest.java @@ -14,10 +14,12 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.validation.ConstraintViolationException; @@ -627,9 +629,10 @@ public class DistributionSetManagementTest extends AbstractJpaIntegrationTest { .create(entityFactory.tag().create().name("DistributionSetTag-C")); distributionSetTagManagement.create(entityFactory.tag().create().name("DistributionSetTag-D")); - List ds5Group1 = testdataFactory.createDistributionSets("", 5); - List dsGroup2 = testdataFactory.createDistributionSets("test2", 5); - DistributionSet dsDeleted = testdataFactory.createDistributionSet("deleted"); + List dsGroup1 = testdataFactory.createDistributionSets("", 5); + final String dsGroup2Prefix = "test"; + List dsGroup2 = testdataFactory.createDistributionSets(dsGroup2Prefix, 5); + DistributionSet dsDeleted = testdataFactory.createDistributionSet("testDeleted"); final DistributionSet dsInComplete = distributionSetManagement.create(entityFactory.distributionSet().create() .name("notcomplete").version("1").type(standardDsType.getKey())); @@ -649,185 +652,268 @@ public class DistributionSetManagementTest extends AbstractJpaIntegrationTest { distributionSetManagement.delete(dsDeleted.getId()); dsDeleted = distributionSetManagement.get(dsDeleted.getId()).get(); - ds5Group1 = toggleTagAssignment(ds5Group1, dsTagA).getAssignedEntity(); + dsGroup1 = toggleTagAssignment(dsGroup1, dsTagA).getAssignedEntity(); dsTagA = distributionSetTagRepository.findByNameEquals(dsTagA.getName()).get(); - ds5Group1 = toggleTagAssignment(ds5Group1, dsTagB).getAssignedEntity(); + dsGroup1 = toggleTagAssignment(dsGroup1, dsTagB).getAssignedEntity(); dsTagA = distributionSetTagRepository.findByNameEquals(dsTagA.getName()).get(); dsGroup2 = toggleTagAssignment(dsGroup2, dsTagA).getAssignedEntity(); dsTagA = distributionSetTagRepository.findByNameEquals(dsTagA.getName()).get(); + final List allDistributionSets = Stream + .of(dsGroup1, dsGroup2, Arrays.asList(dsDeleted, dsInComplete, dsNewType)).flatMap(Collection::stream) + .collect(Collectors.toList()); + final List dsGroup1WithGroup2 = Stream.of(dsGroup1, dsGroup2).flatMap(Collection::stream) + .collect(Collectors.toList()); + final int sizeOfAllDistributionSets = allDistributionSets.size(); + // check setup - assertThat(distributionSetRepository.findAll()).hasSize(13); + assertThat(distributionSetRepository.findAll()).hasSize(sizeOfAllDistributionSets); - // Find all - List expected = Lists.newArrayListWithExpectedSize(13); - expected.addAll(ds5Group1); - expected.addAll(dsGroup2); - expected.add(dsDeleted); - expected.add(dsInComplete); - expected.add(dsNewType); + validateFindAll(allDistributionSets); + validateDeleted(dsDeleted, sizeOfAllDistributionSets - 1); + validateCompleted(dsInComplete, sizeOfAllDistributionSets - 1); + validateType(newType, dsNewType, sizeOfAllDistributionSets - 1); + validateSearchText(dsGroup2, "%" + dsGroup2Prefix); + validateFilterString(allDistributionSets, dsGroup2Prefix); + validateTags(dsTagA, dsTagB, dsTagC, dsGroup1WithGroup2, dsGroup1); + validateDeletedAndCompleted(dsGroup1WithGroup2, dsNewType, dsDeleted); + validateDeletedAndCompletedAndType(dsGroup1WithGroup2, dsDeleted, newType, dsNewType); + validateDeletedAndCompletedAndTypeAndSearchText(dsGroup2, newType, "%" + dsGroup2Prefix); + validateDeletedAndCompletedAndTypeAndFilterString(dsGroup1WithGroup2, dsDeleted, dsInComplete, dsNewType, + newType, ":1"); + validateDeletedAndCompletedAndTypeAndSearchTextAndTag(dsGroup2, dsTagA, "%" + dsGroup2Prefix); + } - assertThat(distributionSetManagement - .findByDistributionSetFilter(PAGE, getDistributionSetFilterBuilder().build()).getContent()).hasSize(13) - .containsOnly(expected.toArray(new DistributionSet[0])); + @Step + private void validateFindAll(final List expectedDistributionsets) { - DistributionSetFilterBuilder distributionSetFilterBuilder; + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder(), expectedDistributionsets); + } - // search for not deleted - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsDeleted(Boolean.TRUE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1); + @Step + private void validateDeleted(final DistributionSet deletedDistributionSet, final int notDeletedSize) { - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsDeleted(false); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(12); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setIsDeleted(Boolean.TRUE), + Arrays.asList(deletedDistributionSet)); - // search for completed - expected = new ArrayList<>(); - expected.addAll(ds5Group1); - expected.addAll(dsGroup2); - expected.add(dsDeleted); - expected.add(dsNewType); + assertThatFilterHasSizeAndDoesNotContainDistributionSet( + getDistributionSetFilterBuilder().setIsDeleted(Boolean.FALSE), notDeletedSize, deletedDistributionSet); + } - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(true); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(12).containsOnly(expected.toArray(new DistributionSet[0])); + @Step + private void validateCompleted(final DistributionSet dsIncomplete, final int completedSize) { - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.FALSE); - expected = new ArrayList<>(); - expected.add(dsInComplete); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1).containsOnly(expected.toArray(new DistributionSet[0])); + assertThatFilterHasSizeAndDoesNotContainDistributionSet( + getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE), completedSize, dsIncomplete); - // search for type - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setType(newType); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setIsComplete(Boolean.FALSE), Arrays.asList(dsIncomplete)); + } - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setType(standardDsType); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(12); + @Step + private void validateType(final DistributionSetType newType, final DistributionSet dsNewType, + final int standardDsTypeSize) { - // search for text - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setSearchText("%test2"); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(5); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setType(newType), + Arrays.asList(dsNewType)); - // search for tags - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagA.getName())); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(10); + assertThatFilterHasSizeAndDoesNotContainDistributionSet( + getDistributionSetFilterBuilder().setType(standardDsType), standardDsTypeSize, dsNewType); + } - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagB.getName())); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(5); + @Step + private void validateSearchText(final List withText, final String text) { - distributionSetFilterBuilder = getDistributionSetFilterBuilder() - .setTagNames(Arrays.asList(dsTagA.getName(), dsTagB.getName())); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(10); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setSearchText(text), + withText); + } - distributionSetFilterBuilder = getDistributionSetFilterBuilder() - .setTagNames(Arrays.asList(dsTagC.getName(), dsTagB.getName())); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(5); + @Step + private void validateFilterString(final List allDistributionSets, final String dsNamePrefix) { - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagC.getName())); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + final List withTestNamePrefix = allDistributionSets.stream() + .filter(ds -> ds.getName().startsWith(dsNamePrefix)).collect(Collectors.toList()); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setFilterString(dsNamePrefix), withTestNamePrefix); - // combine deleted and complete - expected = new ArrayList<>(); - expected.addAll(ds5Group1); - expected.addAll(dsGroup2); - expected.add(dsNewType); + final List withTestNameExact = withTestNamePrefix.stream() + .filter(ds -> ds.getName().equals(dsNamePrefix)).collect(Collectors.toList()); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setFilterString(dsNamePrefix + ":"), withTestNameExact); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) - .setIsDeleted(Boolean.FALSE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(11).containsOnly(expected.toArray(new DistributionSet[0])); + final List withTestNameExactAndVersionPrefix = withTestNameExact.stream() + .filter(ds -> ds.getVersion().startsWith("1")).collect(Collectors.toList()); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setFilterString(dsNamePrefix + ":1"), + withTestNameExactAndVersionPrefix); - expected = Arrays.asList(dsInComplete); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.FALSE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1).containsOnly(expected.toArray(new DistributionSet[0])); + final List dsWithExactNameAndVersion = withTestNameExactAndVersionPrefix.stream() + .filter(ds -> ds.getVersion().equals("1.0.0")).collect(Collectors.toList()); + assertThat(dsWithExactNameAndVersion).hasSize(1); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setFilterString(dsNamePrefix + ":1.0.0"), dsWithExactNameAndVersion); - expected = Arrays.asList(dsDeleted); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) - .setIsDeleted(Boolean.TRUE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1).containsOnly(expected.toArray(new DistributionSet[0])); + final List withVersionPrefix = allDistributionSets.stream() + .filter(ds -> ds.getVersion().startsWith("1.0.")).collect(Collectors.toList()); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setFilterString(":1.0."), + withVersionPrefix); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsDeleted(Boolean.TRUE) - .setIsComplete(Boolean.FALSE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + final List withVersionExact = withVersionPrefix.stream() + .filter(ds -> ds.getVersion().equals("1.0.0")).collect(Collectors.toList()); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setFilterString(":1.0.0"), + withVersionExact); - // combine deleted and complete and type - expected = new ArrayList<>(); - expected.addAll(ds5Group1); - expected.addAll(dsGroup2); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsDeleted(Boolean.FALSE) - .setIsComplete(Boolean.TRUE).setType(standardDsType); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(10).containsOnly(expected.toArray(new DistributionSet[0])); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setFilterString(":"), + allDistributionSets); - expected = Arrays.asList(dsDeleted); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) - .setType(standardDsType).setIsDeleted(Boolean.TRUE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1).containsOnly(expected.toArray(new DistributionSet[0])); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setFilterString(" : "), + allDistributionSets); + } - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsDeleted(Boolean.TRUE) - .setIsComplete(Boolean.FALSE).setType(standardDsType); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + @Step + private void validateTags(final DistributionSetTag dsTagA, final DistributionSetTag dsTagB, + final DistributionSetTag dsTagC, final List dsWithTagA, + final List dsWithTagB) { - expected = Arrays.asList(dsNewType); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE).setType(newType); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(1).containsOnly(expected.toArray(new DistributionSet[0])); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagA.getName())), dsWithTagA); - // combine deleted and complete and type and text - expected = dsGroup2; - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) - .setType(standardDsType).setSearchText("%test2"); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(5).containsOnly(expected.toArray(new DistributionSet[0])); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagB.getName())), dsWithTagB); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) - .setIsDeleted(Boolean.TRUE).setType(standardDsType).setSearchText("%test2"); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagA.getName(), dsTagB.getName())), + dsWithTagA); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setType(standardDsType).setSearchText("%test2") - .setIsComplete(false).setIsDeleted(false); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagC.getName(), dsTagB.getName())), + dsWithTagB); - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setType(newType).setSearchText("%test2") - .setIsComplete(Boolean.TRUE).setIsDeleted(false); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + assertThatFilterDoesNotContainAnyDistributionSet( + getDistributionSetFilterBuilder().setTagNames(Arrays.asList(dsTagC.getName()))); + } - // combine deleted and complete and type and text and tag - expected = dsGroup2; - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setIsComplete(true).setType(standardDsType) - .setSearchText("%test2").setTagNames(Arrays.asList(dsTagA.getName())); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(5).containsOnly(expected.toArray(new DistributionSet[0])); + @Step + private void validateDeletedAndCompleted(final List completedStandardType, + final DistributionSet dsNewType, final DistributionSet dsDeleted) { - distributionSetFilterBuilder = getDistributionSetFilterBuilder().setType(standardDsType).setSearchText("%test2") - .setTagNames(Arrays.asList(dsTagA.getName())).setIsComplete(Boolean.FALSE).setIsDeleted(Boolean.FALSE); - assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, distributionSetFilterBuilder.build()) - .getContent()).hasSize(0); + final List completedNotDeleted = new ArrayList<>(completedStandardType); + completedNotDeleted.add(dsNewType); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE).setIsDeleted(Boolean.FALSE), + completedNotDeleted); + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE).setIsDeleted(Boolean.TRUE), + Arrays.asList(dsDeleted)); + + assertThatFilterDoesNotContainAnyDistributionSet( + getDistributionSetFilterBuilder().setIsComplete(Boolean.FALSE).setIsDeleted(Boolean.TRUE)); + } + + @Step + private void validateDeletedAndCompletedAndType(final List deletedAndCompletedAndStandardType, + final DistributionSet dsDeleted, final DistributionSetType newType, final DistributionSet dsNewType) { + + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setIsDeleted(Boolean.FALSE) + .setIsComplete(Boolean.TRUE).setType(standardDsType), deletedAndCompletedAndStandardType); + + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) + .setType(standardDsType).setIsDeleted(Boolean.TRUE), Arrays.asList(dsDeleted)); + + assertThatFilterDoesNotContainAnyDistributionSet(getDistributionSetFilterBuilder().setIsDeleted(Boolean.TRUE) + .setIsComplete(Boolean.FALSE).setType(standardDsType)); + + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE).setType(newType), + Arrays.asList(dsNewType)); + } + + @Step + private void validateDeletedAndCompletedAndTypeAndSearchText( + final List completedAndStandardTypeAndSearchText, final DistributionSetType newType, + final String text) { + + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) + .setType(standardDsType).setSearchText(text), completedAndStandardTypeAndSearchText); + + assertThatFilterDoesNotContainAnyDistributionSet(getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) + .setIsDeleted(Boolean.TRUE).setType(standardDsType).setSearchText(text)); + + assertThatFilterDoesNotContainAnyDistributionSet(getDistributionSetFilterBuilder().setType(standardDsType) + .setSearchText(text).setIsComplete(Boolean.FALSE).setIsDeleted(Boolean.FALSE)); + + assertThatFilterDoesNotContainAnyDistributionSet(getDistributionSetFilterBuilder().setType(newType) + .setSearchText(text).setIsComplete(Boolean.TRUE).setIsDeleted(Boolean.FALSE)); + } + + @Step + private void validateDeletedAndCompletedAndTypeAndFilterString( + final List completedAndNotDeletedStandardTypeAndFilterString, + final DistributionSet dsDeleted, final DistributionSet dsInComplete, final DistributionSet dsNewType, + final DistributionSetType newType, final String filterString) { + + final List completedAndStandardTypeAndFilterString = new ArrayList<>( + completedAndNotDeletedStandardTypeAndFilterString); + completedAndStandardTypeAndFilterString.add(dsDeleted); + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) + .setType(standardDsType).setFilterString(filterString), completedAndStandardTypeAndFilterString); + + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE).setIsDeleted(Boolean.FALSE) + .setType(standardDsType).setFilterString(filterString), + completedAndNotDeletedStandardTypeAndFilterString); + + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE) + .setIsDeleted(Boolean.TRUE).setType(standardDsType).setFilterString(filterString), + Arrays.asList(dsDeleted)); + + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setType(standardDsType) + .setFilterString(filterString).setIsComplete(Boolean.FALSE).setIsDeleted(Boolean.FALSE), + Arrays.asList(dsInComplete)); + + assertThatFilterContainsOnlyGivenDistributionSets(getDistributionSetFilterBuilder().setType(newType) + .setFilterString(filterString).setIsComplete(Boolean.TRUE).setIsDeleted(Boolean.FALSE), + Arrays.asList(dsNewType)); + } + + @Step + private void validateDeletedAndCompletedAndTypeAndSearchTextAndTag( + final List completedAndStandartTypeAndSearchTextAndTagA, final DistributionSetTag dsTagA, + final String text) { + + assertThatFilterContainsOnlyGivenDistributionSets( + getDistributionSetFilterBuilder().setIsComplete(Boolean.TRUE).setType(standardDsType) + .setSearchText(text).setTagNames(Arrays.asList(dsTagA.getName())), + completedAndStandartTypeAndSearchTextAndTagA); + + assertThatFilterDoesNotContainAnyDistributionSet(getDistributionSetFilterBuilder().setType(standardDsType) + .setSearchText(text).setTagNames(Arrays.asList(dsTagA.getName())).setIsComplete(Boolean.FALSE) + .setIsDeleted(Boolean.FALSE)); } private DistributionSetFilterBuilder getDistributionSetFilterBuilder() { return new DistributionSetFilterBuilder(); } + private void assertThatFilterContainsOnlyGivenDistributionSets(final DistributionSetFilterBuilder filterBuilder, + final List distributionSets) { + final int expectedDsSize = distributionSets.size(); + assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, filterBuilder.build()).getContent()) + .hasSize(expectedDsSize).containsOnly(distributionSets.toArray(new DistributionSet[expectedDsSize])); + } + + private void assertThatFilterDoesNotContainAnyDistributionSet(final DistributionSetFilterBuilder filterBuilder) { + assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, filterBuilder.build()).getContent()) + .hasSize(0); + } + + private void assertThatFilterHasSizeAndDoesNotContainDistributionSet( + final DistributionSetFilterBuilder filterBuilder, final int size, final DistributionSet ds) { + assertThat(distributionSetManagement.findByDistributionSetFilter(PAGE, filterBuilder.build()).getContent()) + .hasSize(size).doesNotContain(ds); + } + @Test @Description("Simple DS load without the related data that should be loaded lazy.") public void findDistributionSetsWithoutLazy() { diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryManagementTest.java index 4cab3b8b6..b50362434 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetFilterQueryManagementTest.java @@ -17,7 +17,7 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import java.util.Iterator; +import java.util.Arrays; import java.util.List; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; @@ -26,8 +26,11 @@ import org.eclipse.hawkbit.repository.event.remote.entity.SoftwareModuleCreatedE import org.eclipse.hawkbit.repository.event.remote.entity.TargetCreatedEvent; import org.eclipse.hawkbit.repository.event.remote.entity.TargetFilterQueryCreatedEvent; import org.eclipse.hawkbit.repository.exception.EntityAlreadyExistsException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignActionTypeException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignDistributionSetException; import org.eclipse.hawkbit.repository.exception.QuotaExceededException; import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.Target; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; @@ -39,6 +42,7 @@ import org.springframework.data.domain.PageRequest; import io.qameta.allure.Description; import io.qameta.allure.Feature; +import io.qameta.allure.Step; import io.qameta.allure.Story; /** @@ -108,8 +112,9 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest final DistributionSet set = testdataFactory.createDistributionSet(); // creation is supposed to work as there is no distribution set - assertThatExceptionOfType(QuotaExceededException.class).isThrownBy(() -> targetFilterQueryManagement.create( - entityFactory.targetFilterQuery().create().name("testfilter").set(set.getId()).query("name==target*"))); + assertThatExceptionOfType(QuotaExceededException.class) + .isThrownBy(() -> targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create() + .name("testfilter").autoAssignDistributionSet(set.getId()).query("name==target*"))); } @Test @@ -180,20 +185,76 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest } @Test - @Description("Test assigning a distribution set") + @Description("Test assigning a distribution set for auto assignment with different action types") public void assignDistributionSet() { final String filterName = "target_filter_02"; final TargetFilterQuery targetFilterQuery = targetFilterQueryManagement .create(entityFactory.targetFilterQuery().create().name(filterName).query("name==PendingTargets001")); - final DistributionSet distributionSet = testdataFactory.createDistributionSet(); - targetFilterQueryManagement.updateAutoAssignDS(targetFilterQuery.getId(), distributionSet.getId()); + verifyAutoAssignmentWithDefaultActionType(filterName, targetFilterQuery, distributionSet); + verifyAutoAssignmentWithSoftActionType(filterName, targetFilterQuery, distributionSet); + + verifyAutoAssignmentWithInvalidActionType(targetFilterQuery, distributionSet); + + verifyAutoAssignmentWithIncompleteDs(targetFilterQuery); + + verifyAutoAssignmentWithSoftDeletedDs(targetFilterQuery); + } + + @Step + private void verifyAutoAssignmentWithDefaultActionType(final String filterName, + final TargetFilterQuery targetFilterQuery, final DistributionSet distributionSet) { + targetFilterQueryManagement.updateAutoAssignDS(targetFilterQuery.getId(), distributionSet.getId()); + verifyAutoAssignDsAndActionType(filterName, distributionSet, ActionType.FORCED); + } + + @Step + private void verifyAutoAssignmentWithSoftActionType(final String filterName, + final TargetFilterQuery targetFilterQuery, final DistributionSet distributionSet) { + targetFilterQueryManagement.updateAutoAssignDSWithActionType(targetFilterQuery.getId(), distributionSet.getId(), + ActionType.SOFT); + verifyAutoAssignDsAndActionType(filterName, distributionSet, ActionType.SOFT); + } + + @Step + private void verifyAutoAssignmentWithInvalidActionType(final TargetFilterQuery targetFilterQuery, + final DistributionSet distributionSet) { + // assigning a distribution set with TIMEFORCED action is supposed to + // fail as only FORCED and SOFT action types are allowed + assertThatExceptionOfType(InvalidAutoAssignActionTypeException.class).isThrownBy( + () -> targetFilterQueryManagement.updateAutoAssignDSWithActionType(targetFilterQuery.getId(), + distributionSet.getId(), ActionType.TIMEFORCED)); + } + + @Step + private void verifyAutoAssignmentWithIncompleteDs(final TargetFilterQuery targetFilterQuery) { + final DistributionSet incompleteDistributionSet = distributionSetManagement + .create(entityFactory.distributionSet().create().name("incomplete").version("1") + .type(testdataFactory.findOrCreateDefaultTestDsType())); + + assertThatExceptionOfType(InvalidAutoAssignDistributionSetException.class) + .isThrownBy(() -> targetFilterQueryManagement.updateAutoAssignDS(targetFilterQuery.getId(), + incompleteDistributionSet.getId())); + } + + @Step + private void verifyAutoAssignmentWithSoftDeletedDs(final TargetFilterQuery targetFilterQuery) { + final DistributionSet softDeletedDs = testdataFactory.createDistributionSet("softDeleted"); + assignDistributionSet(softDeletedDs, testdataFactory.createTarget("forSoftDeletedDs")); + distributionSetManagement.delete(softDeletedDs.getId()); + + assertThatExceptionOfType(InvalidAutoAssignDistributionSetException.class).isThrownBy( + () -> targetFilterQueryManagement.updateAutoAssignDS(targetFilterQuery.getId(), softDeletedDs.getId())); + } + + private void verifyAutoAssignDsAndActionType(final String filterName, final DistributionSet distributionSet, + final ActionType actionType) { final TargetFilterQuery tfq = targetFilterQueryManagement.getByName(filterName).get(); assertEquals("Returns correct distribution set", distributionSet, tfq.getAutoAssignDistributionSet()); - + assertEquals("Return correct action type", actionType, tfq.getAutoAssignActionType()); } @Test @@ -225,8 +286,8 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest final DistributionSet set = testdataFactory.createDistributionSet(); // creation is supposed to work as the query does not exceed the quota - final TargetFilterQuery targetFilterQuery = targetFilterQueryManagement.create( - entityFactory.targetFilterQuery().create().name("testfilter").set(set.getId()).query("name==foo")); + final TargetFilterQuery targetFilterQuery = targetFilterQueryManagement.create(entityFactory.targetFilterQuery() + .create().name("testfilter").autoAssignDistributionSet(set.getId()).query("name==foo")); // update with a query string that addresses too many targets assertThatExceptionOfType(QuotaExceededException.class).isThrownBy(() -> targetFilterQueryManagement @@ -247,6 +308,7 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest // Check if target filter query is there TargetFilterQuery tfq = targetFilterQueryManagement.getByName(filterName).get(); assertEquals("Returns correct distribution set", distributionSet, tfq.getAutoAssignDistributionSet()); + assertEquals("Return correct action type", ActionType.FORCED, tfq.getAutoAssignActionType()); distributionSetManagement.delete(distributionSet.getId()); @@ -254,7 +316,7 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest tfq = targetFilterQueryManagement.getByName(filterName).get(); assertNotNull("Returns target filter query", tfq); assertNull("Returns distribution set as null", tfq.getAutoAssignDistributionSet()); - + assertNull("Returns action type as null", tfq.getAutoAssignActionType()); } @Test @@ -275,6 +337,7 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest // Check if target filter query is there with the distribution set TargetFilterQuery tfq = targetFilterQueryManagement.getByName(filterName).get(); assertEquals("Returns correct distribution set", distributionSet, tfq.getAutoAssignDistributionSet()); + assertEquals("Return correct action type", ActionType.FORCED, tfq.getAutoAssignActionType()); distributionSetManagement.delete(distributionSet.getId()); @@ -286,66 +349,66 @@ public class TargetFilterQueryManagementTest extends AbstractJpaIntegrationTest tfq = targetFilterQueryManagement.getByName(filterName).get(); assertNotNull("Returns target filter query", tfq); assertNull("Returns distribution set as null", tfq.getAutoAssignDistributionSet()); - + assertNull("Returns action type as null", tfq.getAutoAssignActionType()); } @Test @Description("Test finding and auto assign distribution set") public void findFiltersWithDistributionSet() { - final String filterName = "d"; - assertEquals(0L, targetFilterQueryManagement.count()); - targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("a").query("name==*")); targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("b").query("name==*")); - final DistributionSet distributionSet = testdataFactory.createDistributionSet(); final DistributionSet distributionSet2 = testdataFactory.createDistributionSet("2"); - final TargetFilterQuery tfq = targetFilterQueryManagement.updateAutoAssignDS( + final TargetFilterQuery tfq = targetFilterQueryManagement.updateAutoAssignDSWithActionType( targetFilterQueryManagement .create(entityFactory.targetFilterQuery().create().name("c").query("name==x")).getId(), - distributionSet.getId()); - + distributionSet.getId(), ActionType.SOFT); final TargetFilterQuery tfq2 = targetFilterQueryManagement.updateAutoAssignDS( targetFilterQueryManagement .create(entityFactory.targetFilterQuery().create().name(filterName).query("name==z*")).getId(), distributionSet2.getId()); - assertEquals(4L, targetFilterQueryManagement.count()); // check if find works - Page tfqList = targetFilterQueryManagement.findByAutoAssignDSAndRsql(PageRequest.of(0, 500), - distributionSet.getId(), null); - assertThat(1L).as("Target filter query").isEqualTo(tfqList.getTotalElements()); - - assertEquals("Returns correct target filter query", tfq.getId(), tfqList.iterator().next().getId()); + verifyFindByDistributionSetAndRsql(distributionSet, null, tfq); targetFilterQueryManagement.updateAutoAssignDS(tfq2.getId(), distributionSet.getId()); // check if find works for two - tfqList = targetFilterQueryManagement.findByAutoAssignDSAndRsql(PageRequest.of(0, 500), distributionSet.getId(), - null); - assertThat(2L).as("Target filter query count").isEqualTo(tfqList.getTotalElements()); - Iterator iterator = tfqList.iterator(); - assertEquals("Returns correct target filter query 1", tfq.getId(), iterator.next().getId()); - assertEquals("Returns correct target filter query 2", tfq2.getId(), iterator.next().getId()); + verifyFindByDistributionSetAndRsql(distributionSet, null, tfq, tfq2); // check if find works with name filter - tfqList = targetFilterQueryManagement.findByAutoAssignDSAndRsql(PageRequest.of(0, 500), distributionSet.getId(), - "name==" + filterName); - assertThat(1L).as("Target filter query count").isEqualTo(tfqList.getTotalElements()); - - assertEquals("Returns correct target filter query", tfq2.getId(), tfqList.iterator().next().getId()); - - // check if find works for all with auto assign DS - tfqList = targetFilterQueryManagement.findWithAutoAssignDS(PageRequest.of(0, 500)); - assertThat(2L).as("Target filter query count").isEqualTo(tfqList.getTotalElements()); - iterator = tfqList.iterator(); - assertEquals("Returns correct target filter query 1", tfq.getId(), iterator.next().getId()); - assertEquals("Returns correct target filter query 2", tfq2.getId(), iterator.next().getId()); + verifyFindByDistributionSetAndRsql(distributionSet, "name==" + filterName, tfq2); + verifyFindForAllWithAutoAssignDs(tfq, tfq2); } + @Step + private void verifyFindByDistributionSetAndRsql(final DistributionSet distributionSet, final String rsql, + final TargetFilterQuery... expectedFilterQueries) { + final Page tfqList = targetFilterQueryManagement + .findByAutoAssignDSAndRsql(PageRequest.of(0, 500), distributionSet.getId(), rsql); + + verifyExpectedFilterQueriesInList(tfqList, expectedFilterQueries); + } + + private void verifyExpectedFilterQueriesInList(final Page tfqList, + final TargetFilterQuery... expectedFilterQueries) { + assertThat(expectedFilterQueries.length).as("Target filter query count") + .isEqualTo((int) tfqList.getTotalElements()); + + assertThat(tfqList.map(TargetFilterQuery::getId)).containsExactly( + Arrays.stream(expectedFilterQueries).map(TargetFilterQuery::getId).toArray(Long[]::new)); + } + + @Step + private void verifyFindForAllWithAutoAssignDs(final TargetFilterQuery... expectedFilterQueries) { + final Page tfqList = targetFilterQueryManagement + .findWithAutoAssignDS(PageRequest.of(0, 500)); + + verifyExpectedFilterQueriesInList(tfqList, expectedFilterQueries); + } } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetManagementTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetManagementTest.java index 817068e38..e15e62a07 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetManagementTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/TargetManagementTest.java @@ -1036,10 +1036,10 @@ public class TargetManagementTest extends AbstractJpaIntegrationTest { final Target target2 = createTargetWithMetadata("target2", 8); final Page metadataOfTarget1 = targetManagement - .findMetaDataByControllerId(new PageRequest(0, 100), target1.getControllerId()); + .findMetaDataByControllerId(PageRequest.of(0, 100), target1.getControllerId()); final Page metadataOfTarget2 = targetManagement - .findMetaDataByControllerId(new PageRequest(0, 100), target2.getControllerId()); + .findMetaDataByControllerId(PageRequest.of(0, 100), target2.getControllerId()); assertThat(metadataOfTarget1.getNumberOfElements()).isEqualTo(10); assertThat(metadataOfTarget1.getTotalElements()).isEqualTo(10); diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerTest.java index 7771078dc..bfa9885ec 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/autoassign/AutoAssignCheckerTest.java @@ -9,11 +9,13 @@ package org.eclipse.hawkbit.repository.jpa.autoassign; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignDistributionSetException; import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; import org.eclipse.hawkbit.repository.model.Action; import org.eclipse.hawkbit.repository.model.Action.ActionType; @@ -80,7 +82,7 @@ public class AutoAssignCheckerTest extends AbstractJpaIntegrationTest { final Action rolloutCreatedAction = actionsByKnownTarget.stream() .filter(action -> !action.getId().equals(manuallyAssignedActionId)).findAny().get(); assertThat(rolloutCreatedAction.getStatus()).isEqualTo(Status.RUNNING); - + assertThat(rolloutCreatedAction.getActionType()).isEqualTo(ActionType.FORCED); } finally { tenantConfigurationManagement .addOrUpdateConfiguration(TenantConfigurationKey.REPOSITORY_ACTIONS_AUTOCLOSE_ENABLED, false); @@ -149,9 +151,13 @@ public class AutoAssignCheckerTest extends AbstractJpaIntegrationTest { // target filter query that matches first bunch of targets, that should // fail - targetFilterQueryManagement.updateAutoAssignDS(targetFilterQueryManagement.create( - entityFactory.targetFilterQuery().create().name("filterA").query("id==" + targetDsFIdPref + "*")) - .getId(), setF.getId()); + assertThatExceptionOfType( + InvalidAutoAssignDistributionSetException.class) + .isThrownBy( + () -> targetFilterQueryManagement.updateAutoAssignDS( + targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create() + .name("filterA").query("id==" + targetDsFIdPref + "*")).getId(), + setF.getId())); // target filter query that matches failed bunch of targets targetFilterQueryManagement.updateAutoAssignDS(targetFilterQueryManagement.create( @@ -204,4 +210,47 @@ public class AutoAssignCheckerTest extends AbstractJpaIntegrationTest { } + @Test + @Description("Test auto assignment of a distribution set with FORCED and SOFT action types") + public void checkAutoAssignWithDifferentActionTypes() { + final DistributionSet distributionSet = testdataFactory.createDistributionSet(); + final String targetDsAIdPref = "targA"; + final String targetDsBIdPref = "targB"; + + final List targetsA = testdataFactory.createTargets(5, targetDsAIdPref, + targetDsAIdPref.concat(" description")); + final List targetsB = testdataFactory.createTargets(10, targetDsBIdPref, + targetDsBIdPref.concat(" description")); + final int targetsCount = targetsA.size() + targetsB.size(); + + targetFilterQueryManagement + .updateAutoAssignDSWithActionType( + targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("filterA") + .query("id==" + targetDsAIdPref + "*")).getId(), + distributionSet.getId(), ActionType.FORCED); + targetFilterQueryManagement + .updateAutoAssignDSWithActionType( + targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("filterB") + .query("id==" + targetDsBIdPref + "*")).getId(), + distributionSet.getId(), ActionType.SOFT); + + autoAssignChecker.check(); + + verifyThatTargetsHaveDistributionSetAssignment(distributionSet, targetsA, targetsCount); + verifyThatTargetsHaveDistributionSetAssignment(distributionSet, targetsB, targetsCount); + + verifyThatTargetsHaveAssignmentActionType(ActionType.FORCED, targetsA); + verifyThatTargetsHaveAssignmentActionType(ActionType.SOFT, targetsB); + } + + @Step + private void verifyThatTargetsHaveAssignmentActionType(final ActionType actionType, final List targets) { + final List actions = targets.stream().map(Target::getControllerId).flatMap( + controllerId -> deploymentManagement.findActionsByTarget(controllerId, PAGE).getContent().stream()) + .collect(Collectors.toList()); + + assertThat(actions).hasSize(targets.size()); + assertThat(actions).allMatch(action -> action.getActionType().equals(actionType)); + } + } diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFilterQueryFieldsTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFilterQueryFieldsTest.java new file mode 100644 index 000000000..17d9dacdc --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetFilterQueryFieldsTest.java @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2019 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.jpa.rsql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertEquals; + +import org.eclipse.hawkbit.repository.TargetFilterQueryFields; +import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; +import org.eclipse.hawkbit.repository.model.Action.ActionType; +import org.eclipse.hawkbit.repository.model.DistributionSet; +import org.eclipse.hawkbit.repository.model.TargetFilterQuery; +import org.eclipse.hawkbit.repository.test.util.TestdataFactory; +import org.junit.Before; +import org.junit.Test; +import org.springframework.data.domain.Page; + +import io.qameta.allure.Description; +import io.qameta.allure.Feature; +import io.qameta.allure.Story; + +@Feature("Component Tests - Repository") +@Story("RSQL filter target filter query") +public class RSQLTargetFilterQueryFieldsTest extends AbstractJpaIntegrationTest { + + private TargetFilterQuery filter1; + private TargetFilterQuery filter2; + + @Before + public void setupBeforeTest() throws InterruptedException { + final String filterName1 = "filter_a"; + final String filterName2 = "filter_b"; + final String filterName3 = "filter_c"; + + final DistributionSet ds1 = testdataFactory.createDistributionSet("AutoAssignedDs_1"); + final DistributionSet ds2 = testdataFactory.createDistributionSet("AutoAssignedDs_2"); + + filter1 = targetFilterQueryManagement.updateAutoAssignDSWithActionType( + targetFilterQueryManagement + .create(entityFactory.targetFilterQuery().create().name(filterName1).query("name==*")).getId(), + ds1.getId(), ActionType.SOFT); + filter2 = targetFilterQueryManagement.updateAutoAssignDS( + targetFilterQueryManagement + .create(entityFactory.targetFilterQuery().create().name(filterName2).query("name==*")).getId(), + ds2.getId()); + targetFilterQueryManagement + .create(entityFactory.targetFilterQuery().create().name(filterName3).query("name==*")); + + assertEquals(3L, targetFilterQueryManagement.count()); + } + + @Test + @Description("Test filter target filter query by id") + public void testFilterByParameterId() { + assertRSQLQuery(TargetFilterQueryFields.ID.name() + "==*", 3); + } + + @Test + @Description("Test filter target filter query by name") + public void testFilterByParameterName() { + assertRSQLQuery(TargetFilterQueryFields.NAME.name() + "==" + filter1.getName(), 1); + assertRSQLQuery(TargetFilterQueryFields.NAME.name() + "==" + filter2.getName(), 1); + assertRSQLQuery(TargetFilterQueryFields.NAME.name() + "==filter_*", 3); + assertRSQLQuery(TargetFilterQueryFields.NAME.name() + "==noExist*", 0); + assertRSQLQuery(TargetFilterQueryFields.NAME.name() + "=in=(" + filter1.getName() + ",notexist)", 1); + assertRSQLQuery(TargetFilterQueryFields.NAME.name() + "=out=(" + filter1.getName() + ",notexist)", 2); + } + + @Test + @Description("Test filter target filter query by auto assigned ds name") + public void testFilterByAutoAssignedDsName() { + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".name==" + + filter1.getAutoAssignDistributionSet().getName(), 1); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".name==" + + filter2.getAutoAssignDistributionSet().getName(), 1); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".name==AutoAssignedDs_*", 2); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".name==noExist*", 0); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".name=in=(" + + filter1.getAutoAssignDistributionSet().getName() + ",notexist)", 1); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".name=out=(" + + filter1.getAutoAssignDistributionSet().getName() + ",notexist)", 1); + } + + @Test + @Description("Test filter target filter query by auto assigned ds version") + public void testFilterByAutoAssignedDsVersion() { + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".version==" + + TestdataFactory.DEFAULT_VERSION, 2); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".version==*1*", 2); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".version==noExist*", 0); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".version=in=(" + + TestdataFactory.DEFAULT_VERSION + ",notexist)", 2); + assertRSQLQuery(TargetFilterQueryFields.AUTOASSIGNDISTRIBUTIONSET.name() + ".version=out=(" + + TestdataFactory.DEFAULT_VERSION + ",notexist)", 0); + } + + private void assertRSQLQuery(final String rsqlParam, final long expectedFilterQueriesSize) { + final Page findTargetFilterQueryPage = targetFilterQueryManagement.findByRsql(PAGE, + rsqlParam); + assertThat(findTargetFilterQueryPage).isNotNull(); + assertThat(findTargetFilterQueryPage.getTotalElements()).isEqualTo(expectedFilterQueriesSize); + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetMetadataFieldsTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetMetadataFieldsTest.java index cefba21bf..b02050656 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetMetadataFieldsTest.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RSQLTargetMetadataFieldsTest.java @@ -72,7 +72,7 @@ public class RSQLTargetMetadataFieldsTest extends AbstractJpaIntegrationTest { private void assertRSQLQuery(final String rsqlParam, final long expectedEntities) { final Page findEnitity = targetManagement - .findMetaDataByControllerIdAndRsql(new PageRequest(0, 100), controllerId, rsqlParam); + .findMetaDataByControllerIdAndRsql(PageRequest.of(0, 100), controllerId, rsqlParam); final long countAllEntities = findEnitity.getTotalElements(); assertThat(findEnitity).isNotNull(); assertThat(countAllEntities).isEqualTo(expectedEntities); diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java new file mode 100644 index 000000000..ce0d8494c --- /dev/null +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtDistributionSetAutoAssignment.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2018 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.mgmt.json.model.targetfilter; + +import org.eclipse.hawkbit.mgmt.json.model.MgmtId; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Request Body of DistributionSet Id and Action Type for target filter auto + * assignment operation. + */ +public class MgmtDistributionSetAutoAssignment extends MgmtId { + + @JsonProperty(required = false) + private MgmtActionType type; + + public MgmtActionType getType() { + return type; + } + + public void setType(final MgmtActionType type) { + this.type = type; + } +} diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java index 2a3acbe53..e32ad2c8b 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/json/model/targetfilter/MgmtTargetFilterQuery.java @@ -9,6 +9,7 @@ package org.eclipse.hawkbit.mgmt.json.model.targetfilter; import org.eclipse.hawkbit.mgmt.json.model.MgmtBaseEntity; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; @@ -36,6 +37,9 @@ public class MgmtTargetFilterQuery extends MgmtBaseEntity { @JsonProperty private Long autoAssignDistributionSet; + @JsonProperty + private MgmtActionType autoAssignActionType; + public Long getFilterId() { return filterId; } @@ -68,4 +72,12 @@ public class MgmtTargetFilterQuery extends MgmtBaseEntity { this.autoAssignDistributionSet = autoAssignDistributionSet; } + public MgmtActionType getAutoAssignActionType() { + return autoAssignActionType; + } + + public void setAutoAssignActionType(final MgmtActionType actionType) { + this.autoAssignActionType = actionType; + } + } diff --git a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetFilterQueryRestApi.java b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetFilterQueryRestApi.java index b04ad5fa2..8c8703d4a 100644 --- a/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetFilterQueryRestApi.java +++ b/hawkbit-rest/hawkbit-mgmt-api/src/main/java/org/eclipse/hawkbit/mgmt/rest/api/MgmtTargetFilterQueryRestApi.java @@ -8,9 +8,9 @@ */ package org.eclipse.hawkbit.mgmt.rest.api; -import org.eclipse.hawkbit.mgmt.json.model.MgmtId; import org.eclipse.hawkbit.mgmt.json.model.PagedList; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet; +import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtDistributionSetAutoAssignment; import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQuery; import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQueryRequestBody; import org.springframework.hateoas.MediaTypes; @@ -135,15 +135,16 @@ public interface MgmtTargetFilterQueryRestApi { * * @param filterId * of the target to change - * @param dsId - * of the Id of the auto assign distribution set + * @param dsIdWithActionType + * id of the distribution set and the action type for auto + * assignment * @return http status */ @PostMapping(value = "/{filterId}/autoAssignDS", consumes = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }, produces = { MediaTypes.HAL_JSON_VALUE, MediaType.APPLICATION_JSON_VALUE }) ResponseEntity postAssignedDistributionSet(@PathVariable("filterId") Long filterId, - @RequestBody MgmtId dsId); + @RequestBody MgmtDistributionSetAutoAssignment dsIdWithActionType); /** * Handles the DELETE request for removing the distribution set for auto diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java index 5525fadaa..0309b8552 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryMapper.java @@ -57,6 +57,7 @@ public final class MgmtTargetFilterQueryMapper { final DistributionSet distributionSet = filter.getAutoAssignDistributionSet(); if (distributionSet != null) { targetRest.setAutoAssignDistributionSet(distributionSet.getId()); + targetRest.setAutoAssignActionType(MgmtRestModelMapper.convertActionType(filter.getAutoAssignActionType())); } targetRest.add(linkTo(methodOn(MgmtTargetFilterQueryRestApi.class).getFilter(filter.getId())).withSelfRel()); diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java index bcb797865..17006fdcd 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/main/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResource.java @@ -10,9 +10,9 @@ package org.eclipse.hawkbit.mgmt.rest.resource; import java.util.List; -import org.eclipse.hawkbit.mgmt.json.model.MgmtId; import org.eclipse.hawkbit.mgmt.json.model.PagedList; import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtDistributionSet; +import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtDistributionSetAutoAssignment; import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQuery; import org.eclipse.hawkbit.mgmt.json.model.targetfilter.MgmtTargetFilterQueryRequestBody; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; @@ -126,9 +126,11 @@ public class MgmtTargetFilterQueryResource implements MgmtTargetFilterQueryRestA @Override public ResponseEntity postAssignedDistributionSet( - @PathVariable("filterId") final Long filterId, @RequestBody final MgmtId dsId) { + @PathVariable("filterId") final Long filterId, + @RequestBody final MgmtDistributionSetAutoAssignment dsIdWithActionType) { - final TargetFilterQuery updateFilter = filterManagement.updateAutoAssignDS(filterId, dsId.getId()); + final TargetFilterQuery updateFilter = filterManagement.updateAutoAssignDSWithActionType(filterId, + dsIdWithActionType.getId(), MgmtRestModelMapper.convertActionType(dsIdWithActionType.getType())); final MgmtTargetFilterQuery response = MgmtTargetFilterQueryMapper.toResponse(updateFilter); MgmtTargetFilterQueryMapper.addLinks(response); diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java index 70e20d2fc..da2523ec9 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetFilterQueryResourceTest.java @@ -20,8 +20,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.eclipse.hawkbit.exception.SpServerError; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignActionTypeException; +import org.eclipse.hawkbit.repository.exception.InvalidAutoAssignDistributionSetException; import org.eclipse.hawkbit.repository.exception.QuotaExceededException; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.eclipse.hawkbit.rest.exception.MessageNotReadableException; @@ -34,6 +38,7 @@ import org.springframework.test.web.servlet.MvcResult; import io.qameta.allure.Description; import io.qameta.allure.Feature; +import io.qameta.allure.Step; import io.qameta.allure.Story; /** @@ -54,6 +59,7 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte private static final String JSON_PATH_FIELD_SIZE = ".size"; private static final String JSON_PATH_FIELD_TOTAL = ".total"; private static final String JSON_PATH_FIELD_AUTO_ASSIGN_DS = ".autoAssignDistributionSet"; + private static final String JSON_PATH_FIELD_AUTO_ASSIGN_ACTION_TYPE = ".autoAssignActionType"; private static final String JSON_PATH_FIELD_EXCEPTION_CLASS = ".exceptionClass"; private static final String JSON_PATH_FIELD_ERROR_CODE = ".errorCode"; @@ -67,6 +73,8 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte private static final String JSON_PATH_ID = JSON_PATH_ROOT + JSON_PATH_FIELD_ID; private static final String JSON_PATH_QUERY = JSON_PATH_ROOT + JSON_PATH_FIELD_QUERY; private static final String JSON_PATH_AUTO_ASSIGN_DS = JSON_PATH_ROOT + JSON_PATH_FIELD_AUTO_ASSIGN_DS; + private static final String JSON_PATH_AUTO_ASSIGN_ACTION_TYPE = JSON_PATH_ROOT + + JSON_PATH_FIELD_AUTO_ASSIGN_ACTION_TYPE; private static final String JSON_PATH_EXCEPTION_CLASS = JSON_PATH_ROOT + JSON_PATH_FIELD_EXCEPTION_CLASS; private static final String JSON_PATH_ERROR_CODE = JSON_PATH_ROOT + JSON_PATH_FIELD_ERROR_CODE; @@ -113,9 +121,9 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte mvc.perform(put(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId()).content(body) .contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) - .andExpect(jsonPath("$.id", equalTo(tfq.getId().intValue()))) - .andExpect(jsonPath("$.query", equalTo(filterQuery2))) - .andExpect(jsonPath("$.name", equalTo(filterName))); + .andExpect(jsonPath(JSON_PATH_ID, equalTo(tfq.getId().intValue()))) + .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(filterQuery2))) + .andExpect(jsonPath(JSON_PATH_NAME, equalTo(filterName))); final TargetFilterQuery tfqCheck = targetFilterQueryManagement.get(tfq.getId()).get(); assertThat(tfqCheck.getQuery()).isEqualTo(filterQuery2); @@ -136,9 +144,9 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte mvc.perform(put(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId()).content(body) .contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) - .andExpect(jsonPath("$.id", equalTo(tfq.getId().intValue()))) - .andExpect(jsonPath("$.query", equalTo(filterQuery))) - .andExpect(jsonPath("$.name", equalTo(filterName2))); + .andExpect(jsonPath(JSON_PATH_ID, equalTo(tfq.getId().intValue()))) + .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(filterQuery))) + .andExpect(jsonPath(JSON_PATH_NAME, equalTo(filterName2))); final TargetFilterQuery tfqCheck = targetFilterQueryManagement.get(tfq.getId()).get(); assertThat(tfqCheck.getQuery()).isEqualTo(filterQuery); @@ -294,7 +302,7 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte // create targets final int maxTargets = quotaManagement.getMaxTargetsPerAutoAssignment(); - testdataFactory.createTargets(maxTargets + 1, "target%s"); + testdataFactory.createTargets(maxTargets + 1, "target"); // create the filter query and the distribution set final DistributionSet set = testdataFactory.createDistributionSet(); @@ -315,7 +323,7 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte // create targets final int maxTargets = quotaManagement.getMaxTargetsPerAutoAssignment(); - testdataFactory.createTargets(maxTargets + 1, "target%s"); + testdataFactory.createTargets(maxTargets + 1, "target"); // create the filter query and the distribution set final DistributionSet set = testdataFactory.createDistributionSet(); @@ -327,8 +335,10 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte .content("{\"id\":" + set.getId() + "}").contentType(MediaType.APPLICATION_JSON)) .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); - assertThat(targetFilterQueryManagement.get(filterQuery.getId()).get().getAutoAssignDistributionSet()) - .isEqualTo(set); + final TargetFilterQuery updatedFilterQuery = targetFilterQueryManagement.get(filterQuery.getId()).get(); + + assertThat(updatedFilterQuery.getAutoAssignDistributionSet()).isEqualTo(set); + assertThat(updatedFilterQuery.getAutoAssignActionType()).isEqualTo(ActionType.FORCED); // update the query of the filter query to trigger a quota hit mvc.perform(put(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + filterQuery.getId()) @@ -340,33 +350,131 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte } @Test + @Description("Ensures that the distribution set auto-assignment works as intended with distribution set and action type validation") public void setAutoAssignDistributionSetToTargetFilterQuery() throws Exception { - final String knownQuery = "name==test05"; final String knownName = "filter05"; final DistributionSet set = testdataFactory.createDistributionSet(); final TargetFilterQuery tfq = createSingleTargetFilterQuery(knownName, knownQuery); - mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") - .content("{\"id\":" + set.getId() + "}").contentType(MediaType.APPLICATION_JSON)) - .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()); + verifyAutoAssignmentWithoutActionType(tfq, set); - assertThat(targetFilterQueryManagement.get(tfq.getId()).get().getAutoAssignDistributionSet()).isEqualTo(set); + verifyAutoAssignmentWithForcedActionType(tfq, set); + verifyAutoAssignmentWithSoftActionType(tfq, set); + + verifyAutoAssignmentWithTimeForcedActionType(tfq, set); + + verifyAutoAssignmentWithUnknownActionType(tfq, set); + + verifyAutoAssignmentWithIncompleteDs(tfq); + + verifyAutoAssignmentWithSoftDeletedDs(tfq); + } + + @Step + private void verifyAutoAssignmentWithoutActionType(final TargetFilterQuery tfq, final DistributionSet set) + throws Exception { + verifyAutoAssignmentByActionType(tfq, set, null); + } + + @Step + private void verifyAutoAssignmentWithForcedActionType(final TargetFilterQuery tfq, final DistributionSet set) + throws Exception { + verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.FORCED); + } + + @Step + private void verifyAutoAssignmentWithSoftActionType(final TargetFilterQuery tfq, final DistributionSet set) + throws Exception { + verifyAutoAssignmentByActionType(tfq, set, MgmtActionType.SOFT); + } + + private void verifyAutoAssignmentByActionType(final TargetFilterQuery tfq, final DistributionSet set, + final MgmtActionType actionType) throws Exception { final String hrefPrefix = "http://localhost" + MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId(); + final String payload = actionType != null + ? "{\"id\":" + set.getId() + ", \"type\":\"" + actionType.getName() + "\"}" + : "{\"id\":" + set.getId() + "}"; + mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") + .content(payload).contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isOk()); + + final TargetFilterQuery updatedFilterQuery = targetFilterQueryManagement.get(tfq.getId()).get(); + final MgmtActionType expectedActionType = actionType != null ? actionType : MgmtActionType.FORCED; + + assertThat(updatedFilterQuery.getAutoAssignDistributionSet()).isEqualTo(set); + assertThat(updatedFilterQuery.getAutoAssignActionType()) + .isEqualTo(MgmtRestModelMapper.convertActionType(expectedActionType)); + mvc.perform(get(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId())) .andDo(MockMvcResultPrinter.print()).andExpect(status().isOk()) - .andExpect(jsonPath(JSON_PATH_NAME, equalTo(knownName))) - .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(knownQuery))) + .andExpect(jsonPath(JSON_PATH_NAME, equalTo(tfq.getName()))) + .andExpect(jsonPath(JSON_PATH_QUERY, equalTo(tfq.getQuery()))) .andExpect(jsonPath(JSON_PATH_AUTO_ASSIGN_DS, equalTo(set.getId().intValue()))) + .andExpect(jsonPath(JSON_PATH_AUTO_ASSIGN_ACTION_TYPE, equalTo(expectedActionType.getName()))) .andExpect(jsonPath("$._links.self.href", equalTo(hrefPrefix))) .andExpect(jsonPath("$._links.autoAssignDS.href", equalTo(hrefPrefix + "/autoAssignDS"))); } + @Step + private void verifyAutoAssignmentWithTimeForcedActionType(final TargetFilterQuery tfq, final DistributionSet set) + throws Exception { + mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") + .content("{\"id\":" + set.getId() + ", \"type\":\"" + MgmtActionType.TIMEFORCED.getName() + "\"}") + .contentType(MediaType.APPLICATION_JSON)).andDo(MockMvcResultPrinter.print()) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath(JSON_PATH_EXCEPTION_CLASS, + equalTo(InvalidAutoAssignActionTypeException.class.getName()))) + .andExpect(jsonPath(JSON_PATH_ERROR_CODE, + equalTo(SpServerError.SP_AUTO_ASSIGN_ACTION_TYPE_INVALID.getKey()))); + } + + @Step + private void verifyAutoAssignmentWithUnknownActionType(final TargetFilterQuery tfq, final DistributionSet set) + throws Exception { + mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") + .content("{\"id\":" + set.getId() + ", \"type\":\"unknown\"}").contentType(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath(JSON_PATH_EXCEPTION_CLASS, equalTo(MessageNotReadableException.class.getName()))) + .andExpect(jsonPath(JSON_PATH_ERROR_CODE, equalTo(SpServerError.SP_REST_BODY_NOT_READABLE.getKey()))); + } + + @Step + private void verifyAutoAssignmentWithIncompleteDs(final TargetFilterQuery tfq) throws Exception { + final DistributionSet incompleteDistributionSet = distributionSetManagement + .create(entityFactory.distributionSet().create().name("incomplete").version("1") + .type(testdataFactory.findOrCreateDefaultTestDsType())); + + mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") + .content("{\"id\":" + incompleteDistributionSet.getId() + "}").contentType(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath(JSON_PATH_EXCEPTION_CLASS, + equalTo(InvalidAutoAssignDistributionSetException.class.getName()))) + .andExpect(jsonPath(JSON_PATH_ERROR_CODE, + equalTo(SpServerError.SP_AUTO_ASSIGN_DISTRIBUTION_SET_INVALID.getKey()))); + } + + @Step + private void verifyAutoAssignmentWithSoftDeletedDs(final TargetFilterQuery tfq) throws Exception { + final DistributionSet softDeletedDs = testdataFactory.createDistributionSet("softDeleted"); + assignDistributionSet(softDeletedDs, testdataFactory.createTarget("forSoftDeletedDs")); + distributionSetManagement.delete(softDeletedDs.getId()); + + mvc.perform(post(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS") + .content("{\"id\":" + softDeletedDs.getId() + "}").contentType(MediaType.APPLICATION_JSON)) + .andDo(MockMvcResultPrinter.print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath(JSON_PATH_EXCEPTION_CLASS, + equalTo(InvalidAutoAssignDistributionSetException.class.getName()))) + .andExpect(jsonPath(JSON_PATH_ERROR_CODE, + equalTo(SpServerError.SP_AUTO_ASSIGN_DISTRIBUTION_SET_INVALID.getKey()))); + } + @Test + @Description("Ensures that the deletion of auto-assignment distribution set works as intended, deleting the auto-assignment action type as well") public void deleteAutoAssignDistributionSetOfTargetFilterQuery() throws Exception { final String knownQuery = "name==test06"; @@ -377,7 +485,10 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte final TargetFilterQuery tfq = createSingleTargetFilterQuery(knownName, knownQuery); targetFilterQueryManagement.updateAutoAssignDS(tfq.getId(), set.getId()); - assertThat(targetFilterQueryManagement.get(tfq.getId()).get().getAutoAssignDistributionSet()).isEqualTo(set); + final TargetFilterQuery updatedFilterQuery = targetFilterQueryManagement.get(tfq.getId()).get(); + + assertThat(updatedFilterQuery.getAutoAssignDistributionSet()).isEqualTo(set); + assertThat(updatedFilterQuery.getAutoAssignActionType()).isEqualTo(ActionType.FORCED); mvc.perform(get(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS")) .andExpect(status().isOk()).andExpect(jsonPath(JSON_PATH_NAME, equalTo(dsName))); @@ -385,7 +496,10 @@ public class MgmtTargetFilterQueryResourceTest extends AbstractManagementApiInte mvc.perform(delete(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS")) .andExpect(status().isNoContent()); - assertThat(targetFilterQueryManagement.get(tfq.getId()).get().getAutoAssignDistributionSet()).isNull(); + final TargetFilterQuery filterQueryWithDeletedDs = targetFilterQueryManagement.get(tfq.getId()).get(); + + assertThat(filterQueryWithDeletedDs.getAutoAssignDistributionSet()).isNull(); + assertThat(filterQueryWithDeletedDs.getAutoAssignActionType()).isNull(); mvc.perform(get(MgmtRestConstants.TARGET_FILTER_V1_REQUEST_MAPPING + "/" + tfq.getId() + "/autoAssignDS")) .andExpect(status().isNoContent()); diff --git a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java index f550a5973..9e9159cbb 100644 --- a/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java +++ b/hawkbit-rest/hawkbit-mgmt-resource/src/test/java/org/eclipse/hawkbit/mgmt/rest/resource/MgmtTargetResourceTest.java @@ -1674,9 +1674,8 @@ public class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest metaData1.put(new JSONObject().put("key", knownKey1).put("value", knownValue1)); metaData1.put(new JSONObject().put("key", knownKey2).put("value", knownValue2)); - mvc.perform( - post("/rest/v1/targets/{targetId}/metadata", knownControllerId).accept(MediaType.APPLICATION_JSON) - .contentType(MediaType.APPLICATION_JSON).content(metaData1.toString())) + mvc.perform(post("/rest/v1/targets/{targetId}/metadata", knownControllerId).accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON).content(metaData1.toString())) .andDo(MockMvcResultPrinter.print()).andExpect(status().isCreated()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8_VALUE)) .andExpect(jsonPath("[0]key", equalTo(knownKey1))).andExpect(jsonPath("[0]value", equalTo(knownValue1))) @@ -1697,14 +1696,13 @@ public class MgmtTargetResourceTest extends AbstractManagementApiIntegrationTest metaData2.put(new JSONObject().put("key", knownKey1 + i).put("value", knownValue1 + i)); } - mvc.perform( - post("/rest/v1/targets/{targetId}/metadata", knownControllerId).accept(MediaType.APPLICATION_JSON) - .contentType(MediaType.APPLICATION_JSON).content(metaData2.toString())) + mvc.perform(post("/rest/v1/targets/{targetId}/metadata", knownControllerId).accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON).content(metaData2.toString())) .andDo(MockMvcResultPrinter.print()).andExpect(status().isForbidden()); // verify that the number of meta data entries has not changed // (we cannot use the PAGE constant here as it tries to sort by ID) - assertThat(targetManagement.findMetaDataByControllerId(new PageRequest(0, Integer.MAX_VALUE), knownControllerId) + assertThat(targetManagement.findMetaDataByControllerId(PageRequest.of(0, Integer.MAX_VALUE), knownControllerId) .getTotalElements()).isEqualTo(metaData1.length()); } diff --git a/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java b/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java index 817733a9d..856e3a39e 100644 --- a/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java +++ b/hawkbit-rest/hawkbit-rest-core/src/main/java/org/eclipse/hawkbit/rest/exception/ResponseExceptionHandler.java @@ -76,7 +76,8 @@ public class ResponseExceptionHandler { ERROR_TO_HTTP_STATUS.put(SpServerError.SP_REPO_CONCURRENT_MODIFICATION, HttpStatus.CONFLICT); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_MAINTENANCE_SCHEDULE_INVALID, HttpStatus.BAD_REQUEST); ERROR_TO_HTTP_STATUS.put(SpServerError.SP_TARGET_ATTRIBUTES_INVALID, HttpStatus.BAD_REQUEST); - + ERROR_TO_HTTP_STATUS.put(SpServerError.SP_AUTO_ASSIGN_ACTION_TYPE_INVALID, HttpStatus.BAD_REQUEST); + ERROR_TO_HTTP_STATUS.put(SpServerError.SP_AUTO_ASSIGN_DISTRIBUTION_SET_INVALID, HttpStatus.BAD_REQUEST); } private static HttpStatus getStatusOrDefault(final SpServerError error) { diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java index db455c6f9..eb680bb3f 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/DistributionSetsDocumentationTest.java @@ -232,10 +232,10 @@ public class DistributionSetsDocumentationTest extends AbstractApiRestDocumentat + SpPermission.READ_REPOSITORY + " and " + SpPermission.READ_TARGET) public void getAutoAssignTargetFilterQueries() throws Exception { final DistributionSet set = testdataFactory.createUpdatedDistributionSet(); - targetFilterQueryManagement - .create(entityFactory.targetFilterQuery().create().name("filter1").query("name==a").set(set)); - targetFilterQueryManagement - .create(entityFactory.targetFilterQuery().create().name("filter2").query("name==b").set(set)); + targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("filter1").query("name==a") + .autoAssignDistributionSet(set)); + targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("filter2").query("name==b") + .autoAssignDistributionSet(set)); mockMvc.perform(get( MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/{distributionSetId}/autoAssignTargetFilters", @@ -257,8 +257,8 @@ public class DistributionSetsDocumentationTest extends AbstractApiRestDocumentat + SpPermission.READ_REPOSITORY + " and " + SpPermission.READ_TARGET) public void getAutoAssignTargetFilterQueriesWithParameters() throws Exception { final DistributionSet set = testdataFactory.createUpdatedDistributionSet(); - targetFilterQueryManagement - .create(entityFactory.targetFilterQuery().create().name("filter1").query("name==a").set(set)); + targetFilterQueryManagement.create(entityFactory.targetFilterQuery().create().name("filter1").query("name==a") + .autoAssignDistributionSet(set)); mockMvc.perform(get( MgmtRestConstants.DISTRIBUTIONSET_V1_REQUEST_MAPPING + "/" + set.getId() + "/autoAssignTargetFilters") diff --git a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java index fa5c277c8..831122a97 100644 --- a/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java +++ b/hawkbit-rest/hawkbit-rest-docs/src/test/java/org/eclipse/hawkbit/rest/mgmt/documentation/TargetFilterQueriesResourceDocumentationTest.java @@ -23,6 +23,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import java.util.HashMap; import java.util.Map; +import org.eclipse.hawkbit.mgmt.json.model.distributionset.MgmtActionType; import org.eclipse.hawkbit.mgmt.rest.api.MgmtRestConstants; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; @@ -76,7 +77,11 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes fieldWithPath("content[].query").description(MgmtApiModelProperties.TARGET_FILTER_QUERY), fieldWithPath("content[].autoAssignDistributionSet") .description(MgmtApiModelProperties.TARGET_FILTER_QUERY_AUTO_ASSIGN_DS_ID) - .type("Number"), + .type(JsonFieldType.NUMBER.toString()), + fieldWithPath("content[].autoAssignActionType") + .description(MgmtApiModelProperties.ACTION_FORCE_TYPE) + .type(JsonFieldType.STRING.toString()) + .attributes(key("value").value("['forced', 'soft']")), fieldWithPath("content[].createdAt").description(ApiModelPropertiesGeneric.CREATED_AT), fieldWithPath("content[].createdBy").description(ApiModelPropertiesGeneric.CREATED_BY), fieldWithPath("content[].lastModifiedAt") @@ -180,7 +185,8 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes public void postAutoAssignDS() throws Exception { final TargetFilterQuery tfq = createTargetFilterQuery(); final DistributionSet distributionSet = createDistributionSet(); - final String filterByDistSet = "{\"id\":\"" + distributionSet.getId() + "\"}"; + final String filterByDistSet = "{\"id\":\"" + distributionSet.getId() + "\", \"type\":\"" + + MgmtActionType.SOFT.getName() + "\"}"; this.mockMvc .perform( @@ -190,7 +196,10 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes .andDo(this.document.document( pathParameters(parameterWithName("targetFilterQueryId") .description(ApiModelPropertiesGeneric.ITEM_ID)), - requestFields(requestFieldWithPath("id").description(MgmtApiModelProperties.DS_ID)), + requestFields(requestFieldWithPath("id").description(MgmtApiModelProperties.DS_ID), + optionalRequestFieldWithPath("type") + .description(MgmtApiModelProperties.ACTION_FORCE_TYPE) + .attributes(key("value").value("['forced', 'soft']"))), getResponseFieldTargetFilterQuery(false))); } @@ -213,7 +222,11 @@ public class TargetFilterQueriesResourceDocumentationTest extends AbstractApiRes fieldWithPath(arrayPrefix + "name").description(ApiModelPropertiesGeneric.NAME), fieldWithPath(arrayPrefix + "query").description(MgmtApiModelProperties.TARGET_FILTER_QUERY), fieldWithPath(arrayPrefix + "autoAssignDistributionSet") - .description(MgmtApiModelProperties.TARGET_FILTER_QUERY_AUTO_ASSIGN_DS_ID).type("Number"), + .description(MgmtApiModelProperties.TARGET_FILTER_QUERY_AUTO_ASSIGN_DS_ID) + .type(JsonFieldType.NUMBER.toString()), + fieldWithPath(arrayPrefix + "autoAssignActionType") + .description(MgmtApiModelProperties.ACTION_FORCE_TYPE).type(JsonFieldType.STRING.toString()) + .attributes(key("value").value("['forced', 'soft']")), fieldWithPath(arrayPrefix + "createdAt").description(ApiModelPropertiesGeneric.CREATED_AT), fieldWithPath(arrayPrefix + "createdBy").description(ApiModelPropertiesGeneric.CREATED_BY), fieldWithPath(arrayPrefix + "lastModifiedAt").description(ApiModelPropertiesGeneric.LAST_MODIFIED_AT), diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/AbstractMetadataPopupLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/AbstractMetadataPopupLayout.java index 8df39a88e..1217dc9ce 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/AbstractMetadataPopupLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/AbstractMetadataPopupLayout.java @@ -62,33 +62,24 @@ import com.vaadin.ui.renderers.ClickableRenderer.RendererClickEvent; * */ public abstract class AbstractMetadataPopupLayout extends CustomComponent { + private static final long serialVersionUID = 1L; private static final String DELETE_BUTTON = "DELETE_BUTTON"; - - private static final long serialVersionUID = -1491218218453167613L; + private static final int INPUT_DEBOUNCE_TIMEOUT = 250; protected static final String VALUE = "value"; - protected static final String KEY = "key"; - protected static final int MAX_METADATA_QUERY = 500; protected VaadinMessageSource i18n; - private final UINotification uiNotification; - protected transient EventBus.UIEventBus eventBus; private TextField keyTextField; - private TextArea valueTextArea; - private Button addIcon; - private Grid metaDataGrid; - private Label headerCaption; - private CommonDialogWindow metadataWindow; private E selectedEntity; @@ -238,7 +229,8 @@ public abstract class AbstractMetadataPopupLayout field) { Object currentValue = field.getValue(); if (field instanceof Table) { @@ -333,7 +333,7 @@ public class CommonDialogWindow extends Window { requiredComponents.addAll(allComponents.stream().filter(this::hasNullValidator).collect(Collectors.toList())); for (final AbstractField field : requiredComponents) { - Object value = getCurrentVaue(currentChangedComponent, newValue, field); + Object value = getCurrentValue(currentChangedComponent, newValue, field); if (Set.class.equals(field.getType())) { value = emptyToNull((Collection) value); @@ -343,10 +343,12 @@ public class CommonDialogWindow extends Window { return false; } - // We need to loop through the entire loop for validity testing. - // Otherwise the UI will only mark the - // first field with errors and then stop. If there are several - // fields with errors, this is bad. + // We need to loop through all of components for validity testing. + // Otherwise the UI will only mark the first field with errors and + // then stop. Setting the value is necessary because not all + // required input fields have empty string validator, but emptiness + // is checked during isValid() call. Setting the value could be + // redundant, check if it could be removed in the future. field.setValue(value); if (!field.isValid()) { valid = false; diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/detailslayout/TargetMetadataDetailsLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/detailslayout/TargetMetadataDetailsLayout.java index de77fd50e..aae34d46f 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/detailslayout/TargetMetadataDetailsLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/common/detailslayout/TargetMetadataDetailsLayout.java @@ -64,7 +64,7 @@ public class TargetMetadataDetailsLayout extends AbstractMetadataDetailsLayout { } selectedTargetId = target.getId(); final List targetMetadataList = targetManagement - .findMetaDataByControllerId(new PageRequest(0, MAX_METADATA_QUERY), target.getControllerId()) + .findMetaDataByControllerId(PageRequest.of(0, MAX_METADATA_QUERY), target.getControllerId()) .getContent(); if (targetMetadataList != null && !targetMetadataList.isEmpty()) { targetMetadataList.forEach(this::setMetadataProperties); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/components/ProxyTargetFilter.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/components/ProxyTargetFilter.java index 75d8d343c..538855ea2 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/components/ProxyTargetFilter.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/components/ProxyTargetFilter.java @@ -8,6 +8,8 @@ */ package org.eclipse.hawkbit.ui.components; +import org.eclipse.hawkbit.repository.model.Action.ActionType; + /** * * @@ -15,7 +17,7 @@ package org.eclipse.hawkbit.ui.components; */ public class ProxyTargetFilter { - private static final long serialVersionUID = 6622060929679084419L; + private static final long serialVersionUID = 1L; private String createdDate; @@ -27,6 +29,7 @@ public class ProxyTargetFilter { private String lastModifiedBy; private String query; private ProxyDistribution autoAssignDistributionSet; + private ActionType autoAssignActionType; public String getCreatedDate() { return createdDate; @@ -95,7 +98,15 @@ public class ProxyTargetFilter { return autoAssignDistributionSet; } - public void setAutoAssignDistributionSet(ProxyDistribution autoAssignDistributionSet) { + public void setAutoAssignDistributionSet(final ProxyDistribution autoAssignDistributionSet) { this.autoAssignDistributionSet = autoAssignDistributionSet; } + + public ActionType getAutoAssignActionType() { + return autoAssignActionType; + } + + public void setAutoAssignActionType(final ActionType autoAssignActionType) { + this.autoAssignActionType = autoAssignActionType; + } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/distributions/dstable/ManageDistBeanQuery.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/distributions/dstable/ManageDistBeanQuery.java index 582724f79..051e4d65c 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/distributions/dstable/ManageDistBeanQuery.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/distributions/dstable/ManageDistBeanQuery.java @@ -8,14 +8,11 @@ */ package org.eclipse.hawkbit.ui.distributions.dstable; -import java.io.IOException; -import java.io.ObjectInputStream; import java.util.ArrayList; import java.util.List; import java.util.Map; import org.eclipse.hawkbit.repository.DistributionSetManagement; -import org.eclipse.hawkbit.repository.OffsetBasedPageRequest; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.DistributionSetFilter; import org.eclipse.hawkbit.repository.model.DistributionSetFilter.DistributionSetFilterBuilder; @@ -26,12 +23,15 @@ import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; import org.eclipse.hawkbit.ui.utils.SpringContextHelper; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.util.StringUtils; import org.vaadin.addons.lazyquerycontainer.AbstractBeanQuery; import org.vaadin.addons.lazyquerycontainer.QueryDefinition; +import com.vaadin.data.util.filter.SimpleStringFilter; + /** * Manage Distributions table bean query. * @@ -42,6 +42,7 @@ public class ManageDistBeanQuery extends AbstractBeanQuery { private Sort sort = new Sort(Direction.ASC, "id"); private String searchText; + private String filterString; private transient DistributionSetManagement distributionSetManagement; private transient Page firstPageDistributionSets; @@ -59,6 +60,17 @@ public class ManageDistBeanQuery extends AbstractBeanQuery { final Object[] sortPropertyIds, final boolean[] sortStates) { super(definition, queryConfig, sortPropertyIds, sortStates); + init(definition, queryConfig, sortPropertyIds, sortStates); + } + + private void init(final QueryDefinition definition, final Map queryConfig, + final Object[] sortPropertyIds, final boolean[] sortStates) { + populateDataFromQueryConfig(queryConfig); + setFilterString(definition); + setupSorting(sortPropertyIds, sortStates); + } + + private void populateDataFromQueryConfig(final Map queryConfig) { if (HawkbitCommonUtil.isNotNullOrEmpty(queryConfig)) { searchText = (String) queryConfig.get(SPUIDefinitions.FILTER_BY_TEXT); if (!StringUtils.isEmpty(searchText)) { @@ -72,13 +84,24 @@ public class ManageDistBeanQuery extends AbstractBeanQuery { dsComplete = (Boolean) queryConfig.get(SPUIDefinitions.FILTER_BY_DS_COMPLETE); } } + } + private void setFilterString(final QueryDefinition definition) { + // if search text is set, we do not want to apply the filter + if (StringUtils.isEmpty(searchText)) { + filterString = definition.getFilters().stream().filter(SimpleStringFilter.class::isInstance) + .map(SimpleStringFilter.class::cast).map(SimpleStringFilter::getFilterString).findAny() + .orElse(null); + } + } + + private void setupSorting(final Object[] sortPropertyIds, final boolean[] sortStates) { if (sortStates != null && sortStates.length > 0) { // Initialize sort sort = new Sort(sortStates[0] ? Direction.ASC : Direction.DESC, (String) sortPropertyIds[0]); // Add sort for (int distId = 1; distId < sortPropertyIds.length; distId++) { - sort.and(new Sort(sortStates[distId] ? Direction.ASC : Direction.DESC, + sort = sort.and(new Sort(sortStates[distId] ? Direction.ASC : Direction.DESC, (String) sortPropertyIds[distId])); } } @@ -93,18 +116,11 @@ public class ManageDistBeanQuery extends AbstractBeanQuery { protected List loadBeans(final int startIndex, final int count) { Page distBeans; final List proxyDistributions = new ArrayList<>(); + if (startIndex == 0 && firstPageDistributionSets != null) { distBeans = firstPageDistributionSets; - } else if (StringUtils.isEmpty(searchText)) { - // if no search filters available - distBeans = getDistributionSetManagement() - .findByCompleted(new OffsetBasedPageRequest(startIndex, count, sort), dsComplete); } else { - final DistributionSetFilter distributionSetFilter = new DistributionSetFilterBuilder().setIsDeleted(false) - .setIsComplete(dsComplete).setSearchText(searchText).setSelectDSWithNoTag(Boolean.FALSE) - .setType(distributionSetType).build(); - distBeans = getDistributionSetManagement().findByDistributionSetFilter( - PageRequest.of(startIndex / count, count, sort), distributionSetFilter); + distBeans = findDistBeans(PageRequest.of(startIndex / count, count, sort)); } for (final DistributionSet distributionSet : distBeans) { @@ -121,17 +137,7 @@ public class ManageDistBeanQuery extends AbstractBeanQuery { @Override public int size() { - if (StringUtils.isEmpty(searchText) && distributionSetType == null) { - // if no search filters available - firstPageDistributionSets = getDistributionSetManagement() - .findByCompleted(PageRequest.of(0, SPUIDefinitions.PAGE_SIZE, sort), dsComplete); - } else { - final DistributionSetFilter distributionSetFilter = new DistributionSetFilterBuilder().setIsDeleted(false) - .setIsComplete(dsComplete).setSearchText(searchText).setSelectDSWithNoTag(Boolean.FALSE) - .setType(distributionSetType).build(); - firstPageDistributionSets = getDistributionSetManagement().findByDistributionSetFilter( - PageRequest.of(0, SPUIDefinitions.PAGE_SIZE, sort), distributionSetFilter); - } + firstPageDistributionSets = findDistBeans(PageRequest.of(0, SPUIDefinitions.PAGE_SIZE, sort)); final long size = firstPageDistributionSets.getTotalElements(); if (size > Integer.MAX_VALUE) { @@ -141,16 +147,23 @@ public class ManageDistBeanQuery extends AbstractBeanQuery { return (int) size; } + private Page findDistBeans(final Pageable pageable) { + if (StringUtils.isEmpty(filterString) && StringUtils.isEmpty(searchText) && distributionSetType == null) { + return getDistributionSetManagement().findByCompleted(pageable, dsComplete); + } else { + final DistributionSetFilter distributionSetFilter = new DistributionSetFilterBuilder() + .setIsDeleted(Boolean.FALSE).setIsComplete(dsComplete).setSearchText(searchText) + .setFilterString(filterString).setSelectDSWithNoTag(Boolean.FALSE).setType(distributionSetType) + .build(); + + return getDistributionSetManagement().findByDistributionSetFilter(pageable, distributionSetFilter); + } + } + private DistributionSetManagement getDistributionSetManagement() { if (distributionSetManagement == null) { distributionSetManagement = SpringContextHelper.getBean(DistributionSetManagement.class); } return distributionSetManagement; } - - @SuppressWarnings("unchecked") - private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException { - in.defaultReadObject(); - firstPageDistributionSets = (Page) in.readObject(); - } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectComboBox.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectComboBox.java new file mode 100644 index 000000000..5ef33775d --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectComboBox.java @@ -0,0 +1,336 @@ +/** + * Copyright (c) 2018 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.hawkbit.ui.distributions.dstable.ManageDistBeanQuery; +import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; +import org.eclipse.hawkbit.ui.utils.SPUILabelDefinitions; +import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; +import org.eclipse.hawkbit.ui.utils.UIMessageIdProvider; +import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; +import org.springframework.util.StringUtils; +import org.vaadin.addons.lazyquerycontainer.BeanQueryFactory; +import org.vaadin.addons.lazyquerycontainer.LazyQueryContainer; +import org.vaadin.addons.lazyquerycontainer.LazyQueryDefinition; +import org.vaadin.addons.lazyquerycontainer.LazyQueryView; +import org.vaadin.addons.lazyquerycontainer.QueryDefinition; +import org.vaadin.addons.lazyquerycontainer.QueryView; + +import com.vaadin.data.Container; +import com.vaadin.data.Item; +import com.vaadin.data.Property; +import com.vaadin.shared.ui.combobox.FilteringMode; +import com.vaadin.ui.ComboBox; + +/** + * Creates a combobox in order to select the distribution set for a target + * filter query auto assignment. + */ +public class DistributionSetSelectComboBox extends ComboBox { + private static final long serialVersionUID = 1L; + + private final VaadinMessageSource i18n; + private String selectedValueCaption; + private Long previousValue; + private String lastFilterString; + + DistributionSetSelectComboBox(final VaadinMessageSource i18n) { + super(); + this.i18n = i18n; + + init(); + initDataSource(); + } + + private void init() { + setScrollToSelectedItem(false); + setNullSelectionAllowed(false); + setSizeFull(); + setId(UIComponentIdProvider.DIST_SET_SELECT_COMBO_ID); + setCaption(i18n.getMessage(UIMessageIdProvider.HEADER_DISTRIBUTION_SET)); + } + + private void initDataSource() { + final Container container = createContainer(); + container.addContainerProperty(SPUILabelDefinitions.VAR_NAME_VERSION, String.class, null); + + setItemCaptionMode(ItemCaptionMode.PROPERTY); + setItemCaptionPropertyId(SPUILabelDefinitions.VAR_NAME_VERSION); + setFilteringMode(FilteringMode.CONTAINS); + + setContainerDataSource(container); + } + + private static Container createContainer() { + final Map queryConfig = new HashMap<>(); + queryConfig.put(SPUIDefinitions.FILTER_BY_DS_COMPLETE, Boolean.TRUE); + + final BeanQueryFactory distributionQF = new BeanQueryFactory<>(ManageDistBeanQuery.class); + distributionQF.setQueryConfiguration(queryConfig); + + final LazyQueryDefinition distribtuinQD = new LazyQueryDefinition(false, SPUIDefinitions.PAGE_SIZE, + SPUILabelDefinitions.VAR_ID); + + final QueryView distributionSetFilterLazyQueryView = new DistributionSetFilterQueryView( + new LazyQueryView(distribtuinQD, distributionQF)); + distributionSetFilterLazyQueryView.sort( + new Object[] { SPUILabelDefinitions.VAR_NAME, SPUILabelDefinitions.VAR_VERSION }, + new boolean[] { true, true }); + + return new LazyQueryContainer(distributionSetFilterLazyQueryView); + } + + /** + * The custom QueryView implementation is only needed to modify the behavior + * when removing the filter (do not refresh the container). In all other + * cases the default LazyQueryView implementation is being reused. + */ + private static class DistributionSetFilterQueryView implements QueryView { + private final QueryView defaultQueryView; + + DistributionSetFilterQueryView(final QueryView defaultQueryView) { + this.defaultQueryView = defaultQueryView; + } + + @Override + public void addFilter(final Filter arg0) { + defaultQueryView.addFilter(arg0); + } + + @Override + public int addItem() { + return defaultQueryView.addItem(); + } + + @Override + public void commit() { + defaultQueryView.commit(); + } + + @Override + public void discard() { + defaultQueryView.discard(); + } + + @Override + public List getAddedItems() { + return defaultQueryView.getAddedItems(); + } + + @Override + public Collection getFilters() { + return defaultQueryView.getFilters(); + } + + @Override + public Item getItem(final int arg0) { + return defaultQueryView.getItem(arg0); + } + + @Override + public List getItemIdList() { + return defaultQueryView.getItemIdList(); + } + + @Override + public int getMaxCacheSize() { + return defaultQueryView.getMaxCacheSize(); + } + + @Override + public List getModifiedItems() { + return defaultQueryView.getModifiedItems(); + } + + @Override + public QueryDefinition getQueryDefinition() { + return defaultQueryView.getQueryDefinition(); + } + + @Override + public List getRemovedItems() { + return defaultQueryView.getRemovedItems(); + } + + @Override + public boolean isModified() { + return defaultQueryView.isModified(); + } + + @Override + public void refresh() { + defaultQueryView.refresh(); + } + + @Override + public void removeAllItems() { + defaultQueryView.removeAllItems(); + } + + /** + * Default implementation of the combobox removes the filter each time + * it builds the options during repaint. However, container should not + * be refreshed here (default LazyQueryView implementation), as this + * would clear all filtered cache entries following by multiple database + * queries. + */ + @Override + public void removeFilter(final Filter filter) { + defaultQueryView.getQueryDefinition().removeFilter(filter); + // no refresh here + } + + @Override + public void removeFilters() { + defaultQueryView.removeFilters(); + } + + @Override + public void removeItem(final int arg0) { + defaultQueryView.removeItem(arg0); + } + + @Override + public void setMaxCacheSize(final int arg0) { + defaultQueryView.setMaxCacheSize(arg0); + } + + @Override + public int size() { + return defaultQueryView.size(); + } + + @Override + public void sort(final Object[] arg0, final boolean[] arg1) { + defaultQueryView.sort(arg0, arg1); + } + } + + /** + * Overriden in order to get the selected distibution set's option caption + * (name:version) from container and preventing multiple calls by saving the + * selected Id. + * + * @param selectedItemId + * the Id of the selected distribution set + */ + @Override + public void setValue(final Object selectedItemId) { + if (selectedItemId != null) { + // Can happen during validation, leading to multiple database + // queries, in order to get the caption property + if (selectedItemId.equals(previousValue)) { + return; + } + selectedValueCaption = Optional.ofNullable(getContainerProperty(selectedItemId, getItemCaptionPropertyId())) + .map(Property::getValue).map(String.class::cast).orElse(""); + } + + super.setValue(selectedItemId); + previousValue = (Long) selectedItemId; + } + + /** + * Overriden in order to return the caption for the selected distribution + * set from cache. Otherwise, it could lead to multiple database queries, + * trying to retrieve the caption from container, when it is not present in + * filtered options. + * + * @param itemId + * the Id of the selected distribution set + * @return the option caption (name:version) of the selected distribution + * set + */ + @Override + public String getItemCaption(final Object itemId) { + if (itemId != null && itemId.equals(getValue()) && !StringUtils.isEmpty(selectedValueCaption)) { + return selectedValueCaption; + } + + return super.getItemCaption(itemId); + } + + /** + * Overriden not to update the filter when the filterstring (value of + * combobox input) was not changed. Otherwise, it would lead to additional + * database requests during combobox page change while scrolling instead of + * retreiving items from container cache. + * + * @param filterString + * value of combobox input + * @param filteringMode + * the filtering mode (starts_with, contains) + * @return SimpleStringFilter to transfer filterstring in container + */ + @Override + protected Filter buildFilter(final String filterString, final FilteringMode filteringMode) { + if (filterStringIsNotChanged(filterString)) { + return null; + } + + final Filter filter = super.buildFilter(filterString, filteringMode); + + refreshContainerIfFilterStringBecomesEmpty(filterString); + + lastFilterString = filterString; + return filter; + } + + private boolean filterStringIsNotChanged(final String filterString) { + return !StringUtils.isEmpty(filterString) && !StringUtils.isEmpty(lastFilterString) + && filterString.equals(lastFilterString); + } + + private void refreshContainerIfFilterStringBecomesEmpty(final String filterString) { + if (StringUtils.isEmpty(filterString) && !StringUtils.isEmpty(lastFilterString)) { + refreshContainer(); + } + } + + /** + * Before setting the value of the selected distribution set we need to + * initialize the container and apply the right filter in order to limit the + * number of entities and save them in container cache. Otherwise, combobox + * will try to find the corresponding id from container, leading to multiple + * database queries. + * + * @param initialFilterString + * value of initial distribution set caption (name:version) + * @return the size of filtered options + */ + public int setInitialValueFilter(final String initialFilterString) { + final Filter filter = buildFilter(initialFilterString, getFilteringMode()); + + if (filter != null) { + final LazyQueryContainer container = (LazyQueryContainer) getContainerDataSource(); + try { + container.addContainerFilter(filter); + return container.size(); + } finally { + container.removeContainerFilter(filter); + } + } + + return 0; + } + + /** + * Refreshes the underlying container, clearing all the caches. + */ + public void refreshContainer() { + final LazyQueryContainer container = (LazyQueryContainer) getContainerDataSource(); + container.refresh(); + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectTable.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectTable.java deleted file mode 100644 index fd296d241..000000000 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectTable.java +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Copyright (c) 2015 Bosch Software Innovations GmbH and others. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.eclipse.hawkbit.ui.filtermanagement; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.eclipse.hawkbit.repository.event.remote.DistributionSetDeletedEvent; -import org.eclipse.hawkbit.repository.event.remote.entity.DistributionSetCreatedEvent; -import org.eclipse.hawkbit.ui.distributions.dstable.ManageDistBeanQuery; -import org.eclipse.hawkbit.ui.distributions.state.ManageDistUIState; -import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; -import org.eclipse.hawkbit.ui.utils.SPUILabelDefinitions; -import org.eclipse.hawkbit.ui.utils.TableColumn; -import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; -import org.eclipse.hawkbit.ui.utils.UIMessageIdProvider; -import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; -import org.vaadin.addons.lazyquerycontainer.BeanQueryFactory; -import org.vaadin.addons.lazyquerycontainer.LazyQueryContainer; -import org.vaadin.addons.lazyquerycontainer.LazyQueryDefinition; -import org.vaadin.spring.events.EventBus.UIEventBus; -import org.vaadin.spring.events.EventScope; -import org.vaadin.spring.events.annotation.EventBusListenerMethod; - -import com.vaadin.data.Container; -import com.vaadin.ui.Table; -import com.vaadin.ui.themes.ValoTheme; - -/** - * Table for selecting a distribution set. - */ -public class DistributionSetSelectTable extends Table { - - private static final long serialVersionUID = -4307487829435471759L; - - private final VaadinMessageSource i18n; - - private final ManageDistUIState manageDistUIState; - - private Container container; - - DistributionSetSelectTable(final VaadinMessageSource i18n, final UIEventBus eventBus, - final ManageDistUIState manageDistUIState) { - this.i18n = i18n; - this.manageDistUIState = manageDistUIState; - setStyleName("sp-table"); - setSizeFull(); - setSelectable(true); - setMultiSelect(false); - setImmediate(true); - addStyleName(ValoTheme.TABLE_NO_VERTICAL_LINES); - addStyleName(ValoTheme.TABLE_SMALL); - populateTableData(); - setColumnCollapsingAllowed(false); - setColumnProperties(); - setId(UIComponentIdProvider.DIST_SET_SELECT_TABLE_ID); - eventBus.subscribe(this); - } - - @EventBusListenerMethod(scope = EventScope.UI) - void onEvents(final List events) { - final Object firstEvent = events.get(0); - if (DistributionSetCreatedEvent.class.isInstance(firstEvent) - || DistributionSetDeletedEvent.class.isInstance(firstEvent)) { - refreshDistributions(); - } - } - - private void populateTableData() { - container = createContainer(); - addContainerproperties(); - setContainerDataSource(container); - setColumnProperties(); - - } - - protected Container createContainer() { - - final Map queryConfiguration = prepareQueryConfigFilters(); - final BeanQueryFactory distributionQF = new BeanQueryFactory<>(ManageDistBeanQuery.class); - - distributionQF.setQueryConfiguration(queryConfiguration); - return new LazyQueryContainer( - new LazyQueryDefinition(true, SPUIDefinitions.PAGE_SIZE, SPUILabelDefinitions.VAR_ID), distributionQF); - } - - private void addContainerproperties() { - /* Create HierarchicalContainer container */ - container.addContainerProperty(SPUILabelDefinitions.NAME, String.class, null); - container.addContainerProperty(SPUILabelDefinitions.VAR_VERSION, String.class, null); - } - - private List getVisbleColumns() { - final List columnList = new ArrayList<>(2); - columnList.add(new TableColumn(SPUILabelDefinitions.NAME, i18n.getMessage("header.name"), 0.6F)); - columnList.add(new TableColumn(SPUILabelDefinitions.VAR_VERSION, i18n.getMessage("header.version"), 0.4F)); - return columnList; - - } - - private void setColumnProperties() { - setVisibleColumns(getVisbleColumns().stream().map(column -> { - setColumnHeader(column.getColumnPropertyId(), column.getColumnHeader()); - setColumnExpandRatio(column.getColumnPropertyId(), column.getExpandRatio()); - return column.getColumnPropertyId(); - }).toArray()); - } - - private Map prepareQueryConfigFilters() { - final Map queryConfig = new HashMap<>(); - manageDistUIState.getManageDistFilters().getSearchText() - .ifPresent(value -> queryConfig.put(SPUIDefinitions.FILTER_BY_TEXT, value)); - - if (null != manageDistUIState.getManageDistFilters().getClickedDistSetType()) { - queryConfig.put(SPUIDefinitions.FILTER_BY_DISTRIBUTION_SET_TYPE, - manageDistUIState.getManageDistFilters().getClickedDistSetType()); - } - - queryConfig.put(SPUIDefinitions.FILTER_BY_DS_COMPLETE, Boolean.TRUE); - - return queryConfig; - } - - private void refreshDistributions() { - final LazyQueryContainer dsContainer = (LazyQueryContainer) getContainerDataSource(); - final int size = dsContainer.size(); - if (size < SPUIDefinitions.MAX_TABLE_ENTRIES) { - refreshTablecontainer(); - } - if (size != 0) { - setData(i18n.getMessage(UIMessageIdProvider.MESSAGE_DATA_AVAILABLE)); - } - } - - private Object getItemIdToSelect() { - return manageDistUIState.getSelectedDistributions().isEmpty() ? null - : manageDistUIState.getSelectedDistributions(); - } - - private void selectRow() { - setValue(getItemIdToSelect()); - } - - private void refreshTablecontainer() { - final LazyQueryContainer dsContainer = (LazyQueryContainer) getContainerDataSource(); - dsContainer.refresh(); - selectRow(); - } - -} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectWindow.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectWindow.java index 4445b0268..ad3a9c59f 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectWindow.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/DistributionSetSelectWindow.java @@ -8,27 +8,30 @@ */ package org.eclipse.hawkbit.ui.filtermanagement; -import java.io.Serializable; +import java.util.function.Consumer; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.repository.exception.EntityNotFoundException; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.repository.model.DistributionSet; import org.eclipse.hawkbit.repository.model.TargetFilterQuery; import org.eclipse.hawkbit.ui.common.CommonDialogWindow; import org.eclipse.hawkbit.ui.common.builder.WindowBuilder; import org.eclipse.hawkbit.ui.components.SPUIComponentProvider; import org.eclipse.hawkbit.ui.decorators.SPUIButtonStyleNoBorderWithIcon; -import org.eclipse.hawkbit.ui.distributions.state.ManageDistUIState; import org.eclipse.hawkbit.ui.filtermanagement.event.CustomFilterUIEvent; +import org.eclipse.hawkbit.ui.management.miscs.AbstractActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupAutoAssignmentLayout; +import org.eclipse.hawkbit.ui.utils.HawkbitCommonUtil; import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; import org.eclipse.hawkbit.ui.utils.UIMessageIdProvider; +import org.eclipse.hawkbit.ui.utils.UINotification; import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; import org.vaadin.spring.events.EventBus; import org.vaadin.spring.events.EventBus.UIEventBus; -import com.vaadin.data.Property; import com.vaadin.server.FontAwesome; import com.vaadin.server.Sizeable; import com.vaadin.ui.Alignment; @@ -44,59 +47,50 @@ import com.vaadin.ui.Window; * Creates a dialog window to select the distribution set for a target filter * query. */ -public class DistributionSetSelectWindow - implements CommonDialogWindow.SaveDialogCloseListener, Property.ValueChangeListener { - - private static final long serialVersionUID = 4752345414134989396L; +public class DistributionSetSelectWindow implements CommonDialogWindow.SaveDialogCloseListener { private final VaadinMessageSource i18n; - - private final DistributionSetSelectTable dsTable; - - private final transient EventBus.UIEventBus eventBus; - - private final transient TargetManagement targetManagement; - - private final transient TargetFilterQueryManagement targetFilterQueryManagement; + private final UINotification notification; + private final EventBus.UIEventBus eventBus; + private final TargetManagement targetManagement; + private final TargetFilterQueryManagement targetFilterQueryManagement; private CheckBox checkBox; + private ActionTypeOptionGroupAutoAssignmentLayout actionTypeOptionGroupLayout; + private DistributionSetSelectComboBox dsCombo; private Long tfqId; DistributionSetSelectWindow(final VaadinMessageSource i18n, final UIEventBus eventBus, - final TargetManagement targetManagement, final TargetFilterQueryManagement targetFilterQueryManagement, - final ManageDistUIState manageDistUIState) { + final UINotification notification, final TargetManagement targetManagement, + final TargetFilterQueryManagement targetFilterQueryManagement) { this.i18n = i18n; - this.dsTable = new DistributionSetSelectTable(i18n, eventBus, manageDistUIState); + this.notification = notification; this.eventBus = eventBus; this.targetManagement = targetManagement; this.targetFilterQueryManagement = targetFilterQueryManagement; } private VerticalLayout initView() { - final Label label = new Label(i18n.getMessage("label.auto.assign.description")); + final Label label = new Label(i18n.getMessage(UIMessageIdProvider.LABEL_AUTO_ASSIGNMENT_DESC)); - checkBox = new CheckBox(i18n.getMessage("label.auto.assign.enable")); + checkBox = new CheckBox(i18n.getMessage(UIMessageIdProvider.LABEL_AUTO_ASSIGNMENT_ENABLE)); checkBox.setId(UIComponentIdProvider.DIST_SET_SELECT_ENABLE_ID); checkBox.setImmediate(true); - checkBox.addValueChangeListener(this); + checkBox.addValueChangeListener( + event -> switchAutoAssignmentInputsVisibility((boolean) event.getProperty().getValue())); - setTableEnabled(false); + actionTypeOptionGroupLayout = new ActionTypeOptionGroupAutoAssignmentLayout(i18n); + dsCombo = new DistributionSetSelectComboBox(i18n); final VerticalLayout verticalLayout = new VerticalLayout(); verticalLayout.addComponent(label); verticalLayout.addComponent(checkBox); - verticalLayout.addComponent(dsTable); + verticalLayout.addComponent(actionTypeOptionGroupLayout); + verticalLayout.addComponent(dsCombo); return verticalLayout; } - private void setValue(final Long distSet) { - checkBox.setValue(distSet != null); - dsTable.setValue(distSet); - dsTable.setCurrentPageFirstItemId(distSet); - dsTable.setNullSelectionAllowed(false); - } - /** * Shows a distribution set select window for the given target filter query * @@ -111,15 +105,13 @@ public class DistributionSetSelectWindow final VerticalLayout verticalLayout = initView(); final DistributionSet distributionSet = tfq.getAutoAssignDistributionSet(); - if (distributionSet != null) { - setValue(distributionSet.getId()); - } else { - setValue(null); - } + final ActionType actionType = tfq.getAutoAssignActionType(); + + setInitialControlValues(distributionSet, actionType); // build window after values are set to view elements final CommonDialogWindow window = new WindowBuilder(SPUIDefinitions.CREATE_UPDATE_WINDOW) - .caption(i18n.getMessage("caption.select.auto.assign.dist")).content(verticalLayout) + .caption(i18n.getMessage(UIMessageIdProvider.CAPTION_SELECT_AUTO_ASSIGN_DS)).content(verticalLayout) .layout(verticalLayout).i18n(i18n).saveDialogCloseListener(this).buildCommonDialogWindow(); window.setId(UIComponentIdProvider.DIST_SET_SELECT_WINDOW_ID); @@ -128,25 +120,36 @@ public class DistributionSetSelectWindow window.setVisible(true); } - /** - * Is triggered when the checkbox value changes - * - * @param event - * change event - */ - @Override - public void valueChange(final Property.ValueChangeEvent event) { - if (checkBox.getValue()) { - setTableEnabled(true); - } else { - dsTable.select(null); - setTableEnabled(false); + private void setInitialControlValues(final DistributionSet distributionSet, final ActionType actionType) { + checkBox.setValue(distributionSet != null); + switchAutoAssignmentInputsVisibility(distributionSet != null); + + final ActionTypeOption actionTypeToSet = ActionTypeOption.getOptionForActionType(actionType) + .orElse(ActionTypeOption.FORCED); + actionTypeOptionGroupLayout.getActionTypeOptionGroup().select(actionTypeToSet); + + if (distributionSet != null) { + final String initialFilterString = HawkbitCommonUtil.getFormattedNameVersion(distributionSet.getName(), + distributionSet.getVersion()); + final int filteredSize = dsCombo.setInitialValueFilter(initialFilterString); + + if (filteredSize <= 0) { + notification.displayValidationError( + i18n.getMessage(UIMessageIdProvider.MESSAGE_SELECTED_DS_NOT_FOUND, initialFilterString)); + dsCombo.refreshContainer(); + dsCombo.setValue(null); + return; + } } + dsCombo.setValue(distributionSet != null ? distributionSet.getId() : null); } - private void setTableEnabled(final boolean enabled) { - dsTable.setEnabled(enabled); - dsTable.setRequired(enabled); + private void switchAutoAssignmentInputsVisibility(final boolean autoAssignmentEnabled) { + actionTypeOptionGroupLayout.setVisible(autoAssignmentEnabled); + + dsCombo.setVisible(autoAssignmentEnabled); + dsCombo.setEnabled(autoAssignmentEnabled); + dsCombo.setRequired(autoAssignmentEnabled); } /** @@ -164,7 +167,7 @@ public class DistributionSetSelectWindow } private boolean isAutoAssignmentEnabledAndDistributionSetSelected() { - return checkBox.getValue() && dsTable.getValue() != null; + return checkBox.getValue() && dsCombo.getValue() != null; } private boolean isAutoAssignmentDisabled() { @@ -177,38 +180,34 @@ public class DistributionSetSelectWindow */ @Override public void saveOrUpdate() { - if (checkBox.getValue() && dsTable.getValue() != null) { - updateTargetFilterQueryDS(tfqId, (Long) dsTable.getValue()); - + if (checkBox.getValue() && dsCombo.getValue() != null) { + final ActionType autoAssignActionType = ((ActionTypeOption) actionTypeOptionGroupLayout + .getActionTypeOptionGroup().getValue()).getActionType(); + updateTargetFilterQueryDS(tfqId, (Long) dsCombo.getValue(), autoAssignActionType); } else if (!checkBox.getValue()) { - updateTargetFilterQueryDS(tfqId, null); - + updateTargetFilterQueryDS(tfqId, null, null); } - } - private void updateTargetFilterQueryDS(final Long targetFilterQueryId, final Long dsId) { + private void updateTargetFilterQueryDS(final Long targetFilterQueryId, final Long dsId, + final ActionType actionType) { final TargetFilterQuery tfq = targetFilterQueryManagement.get(targetFilterQueryId) .orElseThrow(() -> new EntityNotFoundException(TargetFilterQuery.class, targetFilterQueryId)); if (dsId != null) { - confirmWithConsequencesDialog(tfq, dsId); + confirmWithConsequencesDialog(tfq, dsId, actionType); } else { targetFilterQueryManagement.updateAutoAssignDS(targetFilterQueryId, null); eventBus.publish(this, CustomFilterUIEvent.UPDATED_TARGET_FILTER_QUERY); } - } - private void confirmWithConsequencesDialog(final TargetFilterQuery tfq, final Long dsId) { - - final ConfirmConsequencesDialog dialog = new ConfirmConsequencesDialog(tfq, dsId, new ConfirmCallback() { - @Override - public void onConfirmResult(final boolean accepted) { - if (accepted) { - targetFilterQueryManagement.updateAutoAssignDS(tfq.getId(), dsId); - eventBus.publish(this, CustomFilterUIEvent.UPDATED_TARGET_FILTER_QUERY); - } + private void confirmWithConsequencesDialog(final TargetFilterQuery tfq, final Long dsId, + final ActionType actionType) { + final ConfirmConsequencesDialog dialog = new ConfirmConsequencesDialog(tfq, dsId, accepted -> { + if (accepted) { + targetFilterQueryManagement.updateAutoAssignDSWithActionType(tfq.getId(), dsId, actionType); + eventBus.publish(this, CustomFilterUIEvent.UPDATED_TARGET_FILTER_QUERY); } }); @@ -216,7 +215,6 @@ public class DistributionSetSelectWindow UI.getCurrent().addWindow(dialog); dialog.setVisible(true); - } /** @@ -224,26 +222,21 @@ public class DistributionSetSelectWindow * the */ private class ConfirmConsequencesDialog extends Window implements Button.ClickListener { - - private static final long serialVersionUID = 7738545414137389326L; + private static final long serialVersionUID = 1L; private final TargetFilterQuery targetFilterQuery; private final Long distributionSetId; - - private Button okButton; - - private final ConfirmCallback callback; + private final transient Consumer callback; public ConfirmConsequencesDialog(final TargetFilterQuery targetFilterQuery, final Long dsId, - final ConfirmCallback callback) { - super(i18n.getMessage("caption.confirm.assign.consequences")); + final Consumer callback) { + super(i18n.getMessage(UIMessageIdProvider.CAPTION_CONFIRM_AUTO_ASSIGN_CONSEQUENCES)); this.callback = callback; this.targetFilterQuery = targetFilterQuery; this.distributionSetId = dsId; init(); - } private void init() { @@ -260,9 +253,11 @@ public class DistributionSetSelectWindow targetFilterQuery.getQuery()); Label mainTextLabel; if (targetsCount == 0) { - mainTextLabel = new Label(i18n.getMessage("message.confirm.assign.consequences.none")); + mainTextLabel = new Label( + i18n.getMessage(UIMessageIdProvider.MESSAGE_CONFIRM_AUTO_ASSIGN_CONSEQUENCES_NONE)); } else { - mainTextLabel = new Label(i18n.getMessage("message.confirm.assign.consequences.text", targetsCount)); + mainTextLabel = new Label(i18n + .getMessage(UIMessageIdProvider.MESSAGE_CONFIRM_AUTO_ASSIGN_CONSEQUENCES_TEXT, targetsCount)); } layout.addComponent(mainTextLabel); @@ -273,7 +268,7 @@ public class DistributionSetSelectWindow buttonsLayout.addStyleName("actionButtonsMargin"); layout.addComponent(buttonsLayout); - okButton = SPUIComponentProvider.getButton(UIComponentIdProvider.SAVE_BUTTON, + final Button okButton = SPUIComponentProvider.getButton(UIComponentIdProvider.SAVE_BUTTON, i18n.getMessage(UIMessageIdProvider.BUTTON_OK), "", "", true, FontAwesome.SAVE, SPUIButtonStyleNoBorderWithIcon.class); okButton.setSizeUndefined(); @@ -292,24 +287,17 @@ public class DistributionSetSelectWindow buttonsLayout.addComponent(cancelButton); buttonsLayout.setComponentAlignment(cancelButton, Alignment.MIDDLE_LEFT); buttonsLayout.setExpandRatio(cancelButton, 1.0F); - } @Override public void buttonClick(final Button.ClickEvent event) { - if (event.getButton().getId().equals(okButton.getId())) { - callback.onConfirmResult(true); + if (event.getButton().getId().equals(UIComponentIdProvider.SAVE_BUTTON)) { + callback.accept(true); } else { - callback.onConfirmResult(false); + callback.accept(false); } close(); - } } - - @FunctionalInterface - private interface ConfirmCallback extends Serializable { - void onConfirmResult(boolean accepted); - } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java index 634198fa9..0c1a000ca 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java @@ -17,7 +17,6 @@ import org.eclipse.hawkbit.repository.TargetManagement; import org.eclipse.hawkbit.ui.AbstractHawkbitUI; import org.eclipse.hawkbit.ui.SpPermissionChecker; import org.eclipse.hawkbit.ui.UiProperties; -import org.eclipse.hawkbit.ui.distributions.state.ManageDistUIState; import org.eclipse.hawkbit.ui.filtermanagement.event.CustomFilterUIEvent; import org.eclipse.hawkbit.ui.filtermanagement.footer.TargetFilterCountMessageLabel; import org.eclipse.hawkbit.ui.filtermanagement.state.FilterManagementUIState; @@ -68,11 +67,10 @@ public class FilterManagementView extends VerticalLayout implements View { final FilterManagementUIState filterManagementUIState, final TargetFilterQueryManagement targetFilterQueryManagement, final SpPermissionChecker permissionChecker, final UINotification notification, final UiProperties uiProperties, final EntityFactory entityFactory, - final AutoCompleteTextFieldComponent queryTextField, final ManageDistUIState manageDistUIState, - final TargetManagement targetManagement) { + final AutoCompleteTextFieldComponent queryTextField, final TargetManagement targetManagement) { this.targetFilterHeader = new TargetFilterHeader(eventBus, filterManagementUIState, permissionChecker, i18n); this.targetFilterTable = new TargetFilterTable(i18n, notification, eventBus, filterManagementUIState, - targetFilterQueryManagement, manageDistUIState, targetManagement, permissionChecker); + targetFilterQueryManagement, targetManagement, permissionChecker); this.createNewFilterHeader = new CreateOrUpdateFilterHeader(i18n, eventBus, filterManagementUIState, targetFilterQueryManagement, permissionChecker, notification, uiProperties, entityFactory, queryTextField); @@ -113,10 +111,10 @@ public class FilterManagementView extends VerticalLayout implements View { if (custFilterUIEvent == CustomFilterUIEvent.TARGET_FILTER_DETAIL_VIEW) { viewTargetFilterDetailLayout(); } else if (custFilterUIEvent == CustomFilterUIEvent.CREATE_NEW_FILTER_CLICK) { - this.getUI().access(() -> viewCreateTargetFilterLayout()); + this.getUI().access(this::viewCreateTargetFilterLayout); } else if (custFilterUIEvent == CustomFilterUIEvent.EXIT_CREATE_OR_UPDATE_FILTRER_VIEW || custFilterUIEvent == CustomFilterUIEvent.SHOW_FILTER_MANAGEMENT) { - UI.getCurrent().access(() -> viewListView()); + UI.getCurrent().access(this::viewListView); } } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterBeanQuery.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterBeanQuery.java index c71e12d75..5f17f83fe 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterBeanQuery.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterBeanQuery.java @@ -104,6 +104,7 @@ public class TargetFilterBeanQuery extends AbstractBeanQuery final DistributionSet distributionSet = tarFilterQuery.getAutoAssignDistributionSet(); if (distributionSet != null) { proxyTarFilter.setAutoAssignDistributionSet(new ProxyDistribution(distributionSet)); + proxyTarFilter.setAutoAssignActionType(tarFilterQuery.getAutoAssignActionType()); } proxyTargetFilter.add(proxyTarFilter); } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java index 7f76af9ec..32d836a39 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java @@ -16,12 +16,13 @@ import java.util.Map; import org.eclipse.hawkbit.im.authentication.SpPermission; import org.eclipse.hawkbit.repository.TargetFilterQueryManagement; import org.eclipse.hawkbit.repository.TargetManagement; +import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.ui.SpPermissionChecker; import org.eclipse.hawkbit.ui.common.ConfirmationDialog; import org.eclipse.hawkbit.ui.components.ProxyDistribution; import org.eclipse.hawkbit.ui.components.SPUIComponentProvider; import org.eclipse.hawkbit.ui.decorators.SPUIButtonStyleNoBorder; -import org.eclipse.hawkbit.ui.distributions.state.ManageDistUIState; +import org.eclipse.hawkbit.ui.decorators.SPUIButtonStyleNoBorderWithIcon; import org.eclipse.hawkbit.ui.filtermanagement.event.CustomFilterUIEvent; import org.eclipse.hawkbit.ui.filtermanagement.state.FilterManagementUIState; import org.eclipse.hawkbit.ui.utils.SPUIDefinitions; @@ -68,7 +69,7 @@ public class TargetFilterTable extends Table { private final transient TargetFilterQueryManagement targetFilterQueryManagement; - private final DistributionSetSelectWindow dsSelectWindow; + private final transient DistributionSetSelectWindow dsSelectWindow; private final SpPermissionChecker permChecker; @@ -78,8 +79,8 @@ public class TargetFilterTable extends Table { public TargetFilterTable(final VaadinMessageSource i18n, final UINotification notification, final UIEventBus eventBus, final FilterManagementUIState filterManagementUIState, - final TargetFilterQueryManagement targetFilterQueryManagement, final ManageDistUIState manageDistUIState, - final TargetManagement targetManagement, final SpPermissionChecker permChecker) { + final TargetFilterQueryManagement targetFilterQueryManagement, final TargetManagement targetManagement, + final SpPermissionChecker permChecker) { this.i18n = i18n; this.notification = notification; this.eventBus = eventBus; @@ -87,8 +88,8 @@ public class TargetFilterTable extends Table { this.targetFilterQueryManagement = targetFilterQueryManagement; this.permChecker = permChecker; - this.dsSelectWindow = new DistributionSetSelectWindow(i18n, eventBus, targetManagement, - targetFilterQueryManagement, manageDistUIState); + this.dsSelectWindow = new DistributionSetSelectWindow(i18n, eventBus, notification, targetManagement, + targetFilterQueryManagement); setStyleName("sp-table"); setSizeFull(); @@ -110,7 +111,7 @@ public class TargetFilterTable extends Table { || filterEvent == CustomFilterUIEvent.FILTER_BY_CUST_FILTER_TEXT_REMOVE || filterEvent == CustomFilterUIEvent.CREATE_TARGET_FILTER_QUERY || filterEvent == CustomFilterUIEvent.UPDATED_TARGET_FILTER_QUERY) { - UI.getCurrent().access(() -> refreshContainer()); + UI.getCurrent().access(this::refreshContainer); } } @@ -237,14 +238,25 @@ public class TargetFilterTable extends Table { final Item row1 = getItem(itemId); final ProxyDistribution distSet = (ProxyDistribution) row1 .getItemProperty(SPUILabelDefinitions.AUTO_ASSIGN_DISTRIBUTION_SET).getValue(); + final ActionType actionType = (ActionType) row1.getItemProperty(SPUILabelDefinitions.AUTO_ASSIGN_ACTION_TYPE) + .getValue(); + final String buttonId = "distSetButton"; Button updateIcon; if (distSet == null) { - updateIcon = SPUIComponentProvider.getButton(buttonId, i18n.getMessage("button.no.auto.assignment"), - i18n.getMessage("button.auto.assignment.desc"), null, false, null, SPUIButtonStyleNoBorder.class); + updateIcon = SPUIComponentProvider.getButton(buttonId, + i18n.getMessage(UIMessageIdProvider.BUTTON_NO_AUTO_ASSIGNMENT), + i18n.getMessage(UIMessageIdProvider.BUTTON_AUTO_ASSIGNMENT_DESCRIPTION), null, false, null, + SPUIButtonStyleNoBorder.class); } else { - updateIcon = SPUIComponentProvider.getButton(buttonId, distSet.getNameVersion(), - i18n.getMessage("button.auto.assignment.desc"), null, false, null, SPUIButtonStyleNoBorder.class); + updateIcon = actionType.equals(ActionType.FORCED) + ? SPUIComponentProvider.getButton(buttonId, distSet.getNameVersion(), + i18n.getMessage(UIMessageIdProvider.BUTTON_AUTO_ASSIGNMENT_DESCRIPTION), null, false, + FontAwesome.BOLT, SPUIButtonStyleNoBorderWithIcon.class) + : SPUIComponentProvider.getButton(buttonId, distSet.getNameVersion(), + i18n.getMessage(UIMessageIdProvider.BUTTON_AUTO_ASSIGNMENT_DESCRIPTION), null, false, null, + SPUIButtonStyleNoBorder.class); + updateIcon.setSizeUndefined(); } updateIcon.addClickListener(this::onClickOfDistributionSetButton); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/dstable/DistributionTable.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/dstable/DistributionTable.java index 2ce756834..095af4519 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/dstable/DistributionTable.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/dstable/DistributionTable.java @@ -50,8 +50,8 @@ import org.eclipse.hawkbit.ui.management.event.ManagementUIEvent; import org.eclipse.hawkbit.ui.management.event.PinUnpinEvent; import org.eclipse.hawkbit.ui.management.event.RefreshDistributionTableByFilterEvent; import org.eclipse.hawkbit.ui.management.event.SaveActionWindowEvent; -import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupLayout; -import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.AbstractActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupAssignmentLayout; import org.eclipse.hawkbit.ui.management.miscs.MaintenanceWindowLayout; import org.eclipse.hawkbit.ui.management.state.ManagementUIState; import org.eclipse.hawkbit.ui.management.targettable.TargetTable; @@ -125,7 +125,7 @@ public class DistributionTable extends AbstractNamedVersionTable targetIdSetList; List tempIdList; - final ActionType actionType = ((ActionTypeOptionGroupLayout.ActionTypeOption) actionTypeOptionGroupLayout - .getActionTypeOptionGroup().getValue()).getActionType(); - final long forcedTimeStamp = (((ActionTypeOptionGroupLayout.ActionTypeOption) actionTypeOptionGroupLayout - .getActionTypeOptionGroup().getValue()) == ActionTypeOption.AUTO_FORCED) + final ActionType actionType = ((ActionTypeOption) actionTypeOptionGroupLayout.getActionTypeOptionGroup() + .getValue()).getActionType(); + final long forcedTimeStamp = (((ActionTypeOption) actionTypeOptionGroupLayout.getActionTypeOptionGroup() + .getValue()) == ActionTypeOption.AUTO_FORCED) ? actionTypeOptionGroupLayout.getForcedTimeDateField().getValue().getTime() : RepositoryModelConstants.NO_FORCE_TIME; diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/AbstractActionTypeOptionGroupLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/AbstractActionTypeOptionGroupLayout.java new file mode 100644 index 000000000..ddbc4ca7d --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/AbstractActionTypeOptionGroupLayout.java @@ -0,0 +1,124 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.management.miscs; + +import java.util.Arrays; +import java.util.Optional; + +import org.eclipse.hawkbit.repository.model.Action.ActionType; +import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; +import org.eclipse.hawkbit.ui.utils.UIMessageIdProvider; +import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; +import org.vaadin.hene.flexibleoptiongroup.FlexibleOptionGroup; +import org.vaadin.hene.flexibleoptiongroup.FlexibleOptionGroupItemComponent; + +import com.vaadin.server.FontAwesome; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Label; + +/** + * Action type option group abstract layout. + */ +public abstract class AbstractActionTypeOptionGroupLayout extends HorizontalLayout { + private static final long serialVersionUID = 1L; + + protected static final String STYLE_DIST_WINDOW_ACTIONTYPE = "dist-window-actiontype"; + private static final String STYLE_DIST_WINDOW_ACTIONTYPE_LAYOUT = "dist-window-actiontype-horz-layout"; + + protected final VaadinMessageSource i18n; + protected FlexibleOptionGroup actionTypeOptionGroup; + + /** + * Constructor + * + * @param i18n + * VaadinMessageSource + */ + protected AbstractActionTypeOptionGroupLayout(final VaadinMessageSource i18n) { + this.i18n = i18n; + init(); + } + + private void init() { + createOptionGroup(); + setStyleName(STYLE_DIST_WINDOW_ACTIONTYPE_LAYOUT); + setSizeUndefined(); + } + + protected abstract void createOptionGroup(); + + protected void addForcedItemWithLabel() { + final FlexibleOptionGroupItemComponent forceItem = actionTypeOptionGroup + .getItemComponent(ActionTypeOption.FORCED); + forceItem.setStyleName(STYLE_DIST_WINDOW_ACTIONTYPE); + forceItem.setId(UIComponentIdProvider.SAVE_ACTION_RADIO_FORCED); + addComponent(forceItem); + final Label forceLabel = new Label(); + forceLabel.setStyleName("statusIconPending"); + forceLabel.setIcon(FontAwesome.BOLT); + forceLabel.setCaption(i18n.getMessage(UIMessageIdProvider.CAPTION_ACTION_FORCED)); + forceLabel.setDescription(i18n.getMessage(UIMessageIdProvider.TOOLTIP_FORCED_ITEM)); + forceLabel.setStyleName("padding-right-style"); + addComponent(forceLabel); + } + + protected void addSoftItemWithLabel() { + final FlexibleOptionGroupItemComponent softItem = actionTypeOptionGroup.getItemComponent(ActionTypeOption.SOFT); + softItem.setId(UIComponentIdProvider.ACTION_DETAILS_SOFT_ID); + softItem.setStyleName(STYLE_DIST_WINDOW_ACTIONTYPE); + addComponent(softItem); + final Label softLabel = new Label(); + softLabel.setSizeFull(); + softLabel.setCaption(i18n.getMessage(UIMessageIdProvider.CAPTION_ACTION_SOFT)); + softLabel.setDescription(i18n.getMessage(UIMessageIdProvider.TOOLTIP_SOFT_ITEM)); + softLabel.setStyleName("padding-right-style"); + addComponent(softLabel); + } + + /** + * To Set Default option for save. + */ + public void selectDefaultOption() { + actionTypeOptionGroup.select(ActionTypeOption.FORCED); + } + + /** + * Enum which described the options for the action type + * + */ + public enum ActionTypeOption { + FORCED(ActionType.FORCED), SOFT(ActionType.SOFT), AUTO_FORCED(ActionType.TIMEFORCED); + + private final ActionType actionType; + + ActionTypeOption(final ActionType actionType) { + this.actionType = actionType; + } + + public ActionType getActionType() { + return actionType; + } + + /** + * Matches the action type to the option + * + * @param actionType + * the action type to get option for + * @return action type option if matches, otherwise empty Optional + */ + public static Optional getOptionForActionType(final ActionType actionType) { + return Arrays.stream(ActionTypeOption.values()).filter(option -> option.getActionType().equals(actionType)) + .findFirst(); + } + } + + public FlexibleOptionGroup getActionTypeOptionGroup() { + return actionTypeOptionGroup; + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAssignmentLayout.java similarity index 59% rename from hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupLayout.java rename to hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAssignmentLayout.java index 96321906b..6773ef522 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAssignmentLayout.java @@ -1,5 +1,5 @@ /** - * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * Copyright (c) 2019 Bosch Software Innovations GmbH and others. * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 @@ -12,7 +12,6 @@ import java.time.LocalDateTime; import java.util.Date; import java.util.TimeZone; -import org.eclipse.hawkbit.repository.model.Action.ActionType; import org.eclipse.hawkbit.ui.utils.HawkbitCommonUtil; import org.eclipse.hawkbit.ui.utils.SPDateTimeUtil; import org.eclipse.hawkbit.ui.utils.UIComponentIdProvider; @@ -26,23 +25,15 @@ import com.vaadin.data.Property.ValueChangeListener; import com.vaadin.server.FontAwesome; import com.vaadin.shared.ui.datefield.Resolution; import com.vaadin.ui.DateField; -import com.vaadin.ui.HorizontalLayout; import com.vaadin.ui.Label; import com.vaadin.ui.themes.ValoTheme; /** - * Action type option group layout. + * Action type option group layout for manual assignment. */ -public class ActionTypeOptionGroupLayout extends HorizontalLayout { - +public class ActionTypeOptionGroupAssignmentLayout extends AbstractActionTypeOptionGroupLayout { private static final long serialVersionUID = 1L; - private static final String STYLE_DIST_WINDOW_ACTIONTYPE = "dist-window-actiontype"; - - private final VaadinMessageSource i18n; - - private FlexibleOptionGroup actionTypeOptionGroup; - private DateField forcedTimeDateField; /** @@ -51,13 +42,9 @@ public class ActionTypeOptionGroupLayout extends HorizontalLayout { * @param i18n * VaadinMessageSource */ - public ActionTypeOptionGroupLayout(final VaadinMessageSource i18n) { - this.i18n = i18n; - - createOptionGroup(); + public ActionTypeOptionGroupAssignmentLayout(final VaadinMessageSource i18n) { + super(i18n); addValueChangeListener(); - setStyleName("dist-window-actiontype-horz-layout"); - setSizeUndefined(); } private void addValueChangeListener() { @@ -77,37 +64,20 @@ public class ActionTypeOptionGroupLayout extends HorizontalLayout { }); } - private void createOptionGroup() { + @Override + protected void createOptionGroup() { actionTypeOptionGroup = new FlexibleOptionGroup(); actionTypeOptionGroup.addItem(ActionTypeOption.SOFT); actionTypeOptionGroup.addItem(ActionTypeOption.FORCED); actionTypeOptionGroup.addItem(ActionTypeOption.AUTO_FORCED); selectDefaultOption(); - final FlexibleOptionGroupItemComponent forceItem = actionTypeOptionGroup - .getItemComponent(ActionTypeOption.FORCED); - forceItem.setStyleName(STYLE_DIST_WINDOW_ACTIONTYPE); - forceItem.setId(UIComponentIdProvider.SAVE_ACTION_RADIO_FORCED); - addComponent(forceItem); - final Label forceLabel = new Label(); - forceLabel.setStyleName("statusIconPending"); - forceLabel.setIcon(FontAwesome.BOLT); - forceLabel.setCaption(i18n.getMessage(UIMessageIdProvider.CAPTION_ACTION_FORCED)); - forceLabel.setDescription(i18n.getMessage(UIMessageIdProvider.TOOLTIP_FORCED_ITEM)); - forceLabel.setStyleName("padding-right-style"); - addComponent(forceLabel); - - final FlexibleOptionGroupItemComponent softItem = actionTypeOptionGroup.getItemComponent(ActionTypeOption.SOFT); - softItem.setId(UIComponentIdProvider.ACTION_DETAILS_SOFT_ID); - softItem.setStyleName(STYLE_DIST_WINDOW_ACTIONTYPE); - addComponent(softItem); - final Label softLabel = new Label(); - softLabel.setSizeFull(); - softLabel.setCaption(i18n.getMessage(UIMessageIdProvider.CAPTION_ACTION_SOFT)); - softLabel.setDescription(i18n.getMessage(UIMessageIdProvider.TOOLTIP_SOFT_ITEM)); - softLabel.setStyleName("padding-right-style"); - addComponent(softLabel); + addForcedItemWithLabel(); + addSoftItemWithLabel(); + addAutoForceItemWithLabelAndDateField(); + } + private void addAutoForceItemWithLabelAndDateField() { final FlexibleOptionGroupItemComponent autoForceItem = actionTypeOptionGroup .getItemComponent(ActionTypeOption.AUTO_FORCED); autoForceItem.setStyleName(STYLE_DIST_WINDOW_ACTIONTYPE); @@ -138,38 +108,7 @@ public class ActionTypeOptionGroupLayout extends HorizontalLayout { addComponent(forcedTimeDateField); } - /** - * To Set Default option for save. - */ - - public void selectDefaultOption() { - actionTypeOptionGroup.select(ActionTypeOption.FORCED); - } - - /** - * Enum which described the options for the action type - * - */ - public enum ActionTypeOption { - FORCED(ActionType.FORCED), SOFT(ActionType.SOFT), AUTO_FORCED(ActionType.TIMEFORCED); - - private final ActionType actionType; - - ActionTypeOption(final ActionType actionType) { - this.actionType = actionType; - } - - public ActionType getActionType() { - return actionType; - } - } - - public FlexibleOptionGroup getActionTypeOptionGroup() { - return actionTypeOptionGroup; - } - public DateField getForcedTimeDateField() { return forcedTimeDateField; } - } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAutoAssignmentLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAutoAssignmentLayout.java new file mode 100644 index 000000000..d7b518524 --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/miscs/ActionTypeOptionGroupAutoAssignmentLayout.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2019 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.management.miscs; + +import org.eclipse.hawkbit.ui.utils.VaadinMessageSource; +import org.vaadin.hene.flexibleoptiongroup.FlexibleOptionGroup; + +/** + * Action type option group layout for auto assignment. + */ +public class ActionTypeOptionGroupAutoAssignmentLayout extends AbstractActionTypeOptionGroupLayout { + private static final long serialVersionUID = 1L; + + /** + * Constructor + * + * @param i18n + * VaadinMessageSource + */ + public ActionTypeOptionGroupAutoAssignmentLayout(final VaadinMessageSource i18n) { + super(i18n); + } + + @Override + protected void createOptionGroup() { + actionTypeOptionGroup = new FlexibleOptionGroup(); + actionTypeOptionGroup.addItem(ActionTypeOption.SOFT); + actionTypeOptionGroup.addItem(ActionTypeOption.FORCED); + selectDefaultOption(); + + addForcedItemWithLabel(); + addSoftItemWithLabel(); + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetMetadataPopupLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetMetadataPopupLayout.java index baad6b186..0317c4e88 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetMetadataPopupLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetMetadataPopupLayout.java @@ -66,7 +66,7 @@ public class TargetMetadataPopupLayout extends AbstractMetadataPopupLayout getMetadataList() { return Collections.unmodifiableList(targetManagement - .findMetaDataByControllerId(new PageRequest(0, 500), getSelectedEntity().getControllerId()) + .findMetaDataByControllerId(PageRequest.of(0, 500), getSelectedEntity().getControllerId()) .getContent()); } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetTable.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetTable.java index 49e17d00f..cc48ea94f 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetTable.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/management/targettable/TargetTable.java @@ -60,8 +60,8 @@ import org.eclipse.hawkbit.ui.management.event.TargetAddUpdateWindowEvent; import org.eclipse.hawkbit.ui.management.event.TargetFilterEvent; import org.eclipse.hawkbit.ui.management.event.TargetTableEvent; import org.eclipse.hawkbit.ui.management.event.TargetTableEvent.TargetComponentEvent; -import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupLayout; -import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.AbstractActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupAssignmentLayout; import org.eclipse.hawkbit.ui.management.miscs.MaintenanceWindowLayout; import org.eclipse.hawkbit.ui.management.state.ManagementUIState; import org.eclipse.hawkbit.ui.management.state.TargetTableFilters; @@ -142,7 +142,7 @@ public class TargetTable extends AbstractTable { private ConfirmationDialog confirmDialog; - private final ActionTypeOptionGroupLayout actionTypeOptionGroupLayout; + private final ActionTypeOptionGroupAssignmentLayout actionTypeOptionGroupLayout; private final MaintenanceWindowLayout maintenanceWindowLayout; @@ -159,7 +159,7 @@ public class TargetTable extends AbstractTable { this.tagManagement = tagManagement; this.deploymentManagement = deploymentManagement; this.uiProperties = uiProperties; - this.actionTypeOptionGroupLayout = new ActionTypeOptionGroupLayout(i18n); + this.actionTypeOptionGroupLayout = new ActionTypeOptionGroupAssignmentLayout(i18n); this.maintenanceWindowLayout = new MaintenanceWindowLayout(i18n); setItemDescriptionGenerator(new AssignInstalledDSTooltipGenerator()); @@ -864,10 +864,10 @@ public class TargetTable extends AbstractTable { Long distId; List targetIdSetList; List tempIdList; - final ActionType actionType = ((ActionTypeOptionGroupLayout.ActionTypeOption) actionTypeOptionGroupLayout - .getActionTypeOptionGroup().getValue()).getActionType(); - final long forcedTimeStamp = (((ActionTypeOptionGroupLayout.ActionTypeOption) actionTypeOptionGroupLayout - .getActionTypeOptionGroup().getValue()) == ActionTypeOption.AUTO_FORCED) + final ActionType actionType = ((ActionTypeOption) actionTypeOptionGroupLayout.getActionTypeOptionGroup() + .getValue()).getActionType(); + final long forcedTimeStamp = (((ActionTypeOption) actionTypeOptionGroupLayout.getActionTypeOptionGroup() + .getValue()) == ActionTypeOption.AUTO_FORCED) ? actionTypeOptionGroupLayout.getForcedTimeDateField().getValue().getTime() : RepositoryModelConstants.NO_FORCE_TIME; diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/AddUpdateRolloutWindowLayout.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/AddUpdateRolloutWindowLayout.java index 2a2fd98a9..e44ffc9d9 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/AddUpdateRolloutWindowLayout.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/rollout/rollout/AddUpdateRolloutWindowLayout.java @@ -48,8 +48,8 @@ import org.eclipse.hawkbit.ui.common.builder.TextAreaBuilder; import org.eclipse.hawkbit.ui.common.builder.TextFieldBuilder; import org.eclipse.hawkbit.ui.common.builder.WindowBuilder; import org.eclipse.hawkbit.ui.filtermanagement.TargetFilterBeanQuery; -import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupLayout; -import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.AbstractActionTypeOptionGroupLayout.ActionTypeOption; +import org.eclipse.hawkbit.ui.management.miscs.ActionTypeOptionGroupAssignmentLayout; import org.eclipse.hawkbit.ui.rollout.event.RolloutEvent; import org.eclipse.hawkbit.ui.rollout.groupschart.GroupsPieChart; import org.eclipse.hawkbit.ui.utils.SPDateTimeUtil; @@ -112,7 +112,7 @@ public class AddUpdateRolloutWindowLayout extends GridLayout { private static final String DENY_BUTTON_LABEL = "button.deny"; - private final ActionTypeOptionGroupLayout actionTypeOptionGroupLayout; + private final ActionTypeOptionGroupAssignmentLayout actionTypeOptionGroupLayout; private final AutoStartOptionGroupLayout autoStartOptionGroupLayout; @@ -190,7 +190,7 @@ public class AddUpdateRolloutWindowLayout extends GridLayout { final VaadinMessageSource i18n, final UIEventBus eventBus, final TargetFilterQueryManagement targetFilterQueryManagement, final RolloutGroupManagement rolloutGroupManagement, final QuotaManagement quotaManagement) { - actionTypeOptionGroupLayout = new ActionTypeOptionGroupLayout(i18n); + actionTypeOptionGroupLayout = new ActionTypeOptionGroupAssignmentLayout(i18n); autoStartOptionGroupLayout = new AutoStartOptionGroupLayout(i18n); this.rolloutManagement = rolloutManagement; this.rolloutGroupManagement = rolloutGroupManagement; @@ -371,8 +371,8 @@ public class AddUpdateRolloutWindowLayout extends GridLayout { } private ActionType getActionType() { - return ((ActionTypeOptionGroupLayout.ActionTypeOption) actionTypeOptionGroupLayout - .getActionTypeOptionGroup().getValue()).getActionType(); + return ((ActionTypeOption) actionTypeOptionGroupLayout.getActionTypeOptionGroup().getValue()) + .getActionType(); } private AutoStartOptionGroupLayout.AutoStartOption getAutoStartOption() { @@ -1078,8 +1078,7 @@ public class AddUpdateRolloutWindowLayout extends GridLayout { } private void setActionType(final Rollout rollout) { - for (final ActionTypeOptionGroupLayout.ActionTypeOption groupAction : ActionTypeOptionGroupLayout.ActionTypeOption - .values()) { + for (final ActionTypeOption groupAction : ActionTypeOption.values()) { if (groupAction.getActionType() == rollout.getActionType()) { actionTypeOptionGroupLayout.getActionTypeOptionGroup().setValue(groupAction); final SimpleDateFormat format = new SimpleDateFormat(SPUIDefinitions.LAST_QUERY_DATE_FORMAT); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/SPUILabelDefinitions.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/SPUILabelDefinitions.java index ce84dcd33..8e4d26a59 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/SPUILabelDefinitions.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/SPUILabelDefinitions.java @@ -133,6 +133,10 @@ public final class SPUILabelDefinitions { * AUTO ASSIGN DISTRIBUTION SET ID */ public static final String AUTO_ASSIGN_DISTRIBUTION_SET = "autoAssignDistributionSet"; + /** + * AUTO ASSIGN ACTION TYPE + */ + public static final String AUTO_ASSIGN_ACTION_TYPE = "autoAssignActionType"; /** * ASSIGNED DISTRIBUTION Name and Version. */ diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java index 7cb73df05..408a36427 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIComponentIdProvider.java @@ -1179,9 +1179,9 @@ public final class UIComponentIdProvider { public static final String FILTER_SEARCH_ICON_ID = "filter.search.icon"; /** - * Distribution set select table id + * Distribution set select combobox id */ - public static final String DIST_SET_SELECT_TABLE_ID = "distribution.set.select.table"; + public static final String DIST_SET_SELECT_COMBO_ID = "distribution.set.select.combo"; /** * Distribution set select window id diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIMessageIdProvider.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIMessageIdProvider.java index 3fbec4b6b..f9986359b 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIMessageIdProvider.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/utils/UIMessageIdProvider.java @@ -21,6 +21,12 @@ public final class UIMessageIdProvider { public static final String BUTTON_SAVE = "button.save"; + public static final String BUTTON_NO_AUTO_ASSIGNMENT = "button.no.auto.assignment"; + + public static final String BUTTON_AUTO_ASSIGNMENT_DESCRIPTION = "button.auto.assignment.desc"; + + public static final String HEADER_DISTRIBUTION_SET = "header.distributionset"; + public static final String CAPTION_ACTION_FORCED = "label.action.forced"; public static final String CAPTION_ACTION_SOFT = "label.action.soft"; @@ -47,6 +53,10 @@ public final class UIMessageIdProvider { public static final String CAPTION_ARTIFACT_DETAILS_OF = "caption.artifact.details.of"; + public static final String CAPTION_SELECT_AUTO_ASSIGN_DS = "caption.select.auto.assign.dist"; + + public static final String CAPTION_CONFIRM_AUTO_ASSIGN_CONSEQUENCES = "caption.confirm.assign.consequences"; + public static final String CAPTION_CONFIG_CREATE = "caption.config.create"; public static final String CAPTION_CONFIG_EDIT = "caption.config.edit"; @@ -59,6 +69,10 @@ public final class UIMessageIdProvider { public static final String LABEL_CREATE_FILTER = "label.create.filter"; + public static final String LABEL_AUTO_ASSIGNMENT_DESC = "label.auto.assign.description"; + + public static final String LABEL_AUTO_ASSIGNMENT_ENABLE = "label.auto.assign.enable"; + public static final String MESSAGE_NO_DATA = "message.no.data"; public static final String MESSAGE_DATA_AVAILABLE = "message.data.available"; @@ -67,6 +81,12 @@ public final class UIMessageIdProvider { public static final String MESSAGE_ACTION_NOT_ALLOWED = "message.action.not.allowed"; + public static final String MESSAGE_SELECTED_DS_NOT_FOUND = "message.selected.distributionset.not.found"; + + public static final String MESSAGE_CONFIRM_AUTO_ASSIGN_CONSEQUENCES_NONE = "message.confirm.assign.consequences.none"; + + public static final String MESSAGE_CONFIRM_AUTO_ASSIGN_CONSEQUENCES_TEXT = "message.confirm.assign.consequences.text"; + public static final String TOOLTIP_OVERDUE = "tooltip.overdue"; public static final String TOOLTIP_MAXIMIZE = "tooltip.maximize"; diff --git a/hawkbit-ui/src/main/resources/messages.properties b/hawkbit-ui/src/main/resources/messages.properties index 940bf1e1a..433a6d147 100644 --- a/hawkbit-ui/src/main/resources/messages.properties +++ b/hawkbit-ui/src/main/resources/messages.properties @@ -228,7 +228,7 @@ label.target.controller.attrs = Controller attributes label.target.attributes.update.pending = Update pending.. label.target.lastpolldate = Last poll : label.tag.name = Tag name -label.configuration.auth.header = Allow targets to authenticate via a certificate authenticated by an reverse proxy +label.configuration.auth.header = Allow targets to authenticate via a certificate authenticated by a reverse proxy label.configuration.auth.gatewaytoken = Allow a gateway to authenticate and manage multiple targets through a gateway security token label.configuration.auth.targettoken = Allow targets to authenticate directly with their target security token label.configuration.repository.autoclose.action = Autoclose running actions when a new distribution set is assigned @@ -670,6 +670,7 @@ target.not.exists=Target {0} does not exists. Maybe the target was deleted. targets.not.exists=Targets does not exists. Maybe the targets was deleted. distributionsets.not.exists=Distribution sets do not exists. Maybe the sets were deleted. +message.selected.distributionset.not.found=Distribution set {0} does not exist in the repository, is incomplete or deleted. Please select a new one. targettag.not.exists=Target tag {0} does not exists. Maybe the target tag was deleted. caption.entity.target.tag = Target Tag