diff --git a/.gitignore b/.gitignore
index 973d37762..40f1e4fe7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,5 @@ step-controller/step-controller-base-plugins/resources/
/step-cli/step-cli-launcher/src/test/resources/samples/
work/
/step-cli/step-cli-core/src/test/resources/testReports/
-**/.claude
\ No newline at end of file
+dependency-reduced-pom.xml
+**/.claude
diff --git a/pom.xml b/pom.xml
index 66212788a..b0f5e1451 100644
--- a/pom.xml
+++ b/pom.xml
@@ -46,7 +46,7 @@
11
0.0.0-MASTER-SNAPSHOT
- 0.0.0-MASTER-SNAPSHOT
+ 0.0.0-SED-4417-SNAPSHOT
5.0.4
diff --git a/step-controller/step-controller-backend/src/test/resources/reporting/layouts/presets/StepMainReportLayout.json b/step-controller/step-controller-backend/src/test/resources/reporting/layouts/presets/StepMainReportLayout.json
new file mode 100644
index 000000000..c4ed57d7b
--- /dev/null
+++ b/step-controller/step-controller-backend/src/test/resources/reporting/layouts/presets/StepMainReportLayout.json
@@ -0,0 +1,107 @@
+{
+ "id": "69b010aeec94534eb48176db",
+ "name": "Step Main Layout",
+ "layout": {
+ "widgets": [
+ {
+ "widgetType": "errorsWidget",
+ "position": {
+ "row": 1,
+ "column": 1,
+ "widthInCells": 8,
+ "heightInCells": 1
+ }
+ },
+ {
+ "widgetType": "testCases",
+ "position": {
+ "row": 2,
+ "column": 1,
+ "widthInCells": 6,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "testCasesSummary",
+ "position": {
+ "row": 2,
+ "column": 7,
+ "widthInCells": 2,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "keywordsSummary",
+ "position": {
+ "row": 5,
+ "column": 1,
+ "widthInCells": 2,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "executionTree",
+ "position": {
+ "row": 5,
+ "column": 3,
+ "widthInCells": 6,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "keywordsList",
+ "position": {
+ "row": 8,
+ "column": 1,
+ "widthInCells": 6,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "performanceOverview",
+ "position": {
+ "row": 8,
+ "column": 7,
+ "widthInCells": 2,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "errors",
+ "position": {
+ "row": 11,
+ "column": 1,
+ "widthInCells": 8,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "notificationSubscriptionForExecution",
+ "position": {
+ "row": 14,
+ "column": 1,
+ "widthInCells": 4,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "housekeepingSettingsForExecution",
+ "position": {
+ "row": 14,
+ "column": 5,
+ "widthInCells": 4,
+ "heightInCells": 3
+ }
+ },
+ {
+ "widgetType": "currentOperations",
+ "position": {
+ "row": 17,
+ "column": 1,
+ "widthInCells": 4,
+ "heightInCells": 3
+ }
+ }
+ ]
+ }
+}
diff --git a/step-controller/step-controller-backend/src/test/resources/step.properties b/step-controller/step-controller-backend/src/test/resources/step.properties
index ed348bbe9..f8f7b5870 100644
--- a/step-controller/step-controller-backend/src/test/resources/step.properties
+++ b/step-controller/step-controller-backend/src/test/resources/step.properties
@@ -94,3 +94,8 @@ plugins.FunctionPackagePlugin.maven.repository.central.url=https://repo1.maven.o
#timeseries.collections.week.flush.period=3600000
#timeseries.response.intervals.ideal=100
#timeseries.response.intervals.max=1000
+#-----------------------------------------------------
+# Reporting
+#-----------------------------------------------------
+plugins.reporting.layouts.presets.folder=src/test/resources/reporting/layouts/presets
+plugins.reporting.layouts.default.id=69b010aeec94534eb48176db
diff --git a/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java b/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java
index 555474d5e..ebce0ca74 100644
--- a/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java
+++ b/step-controller/step-controller-server/src/main/java/step/controller/services/entities/AbstractEntityServices.java
@@ -8,6 +8,7 @@
import step.automation.packages.AutomationPackageEntity;
import step.controller.services.async.AsyncTaskStatus;
import step.core.GlobalContext;
+import step.core.access.User;
import step.core.accessors.AbstractIdentifiableObject;
import step.core.accessors.AbstractOrganizableObject;
import step.core.accessors.AbstractTrackedObject;
@@ -152,11 +153,19 @@ private void trackEntityIfApplicable(T entity) {
AbstractTrackedObject newTrackedEntity = (AbstractTrackedObject) entity;
ObjectId sourceId = entity.getId();
T sourceEntity = (sourceId != null) ? accessor.get(sourceId) : null;
- String username = getSession().getUser().getUsername();
+ User user = getSession().getUser();
+ String username = user.getUsername();
+ String userId = user.getId().toHexString();
Date lastModificationDate = new Date();
- if (sourceEntity == null) {
+ //If source is null or not an instance of AbstractTrackedObject, we set creation metadata
+ if (!(sourceEntity instanceof AbstractTrackedObject)) {
newTrackedEntity.setCreationDate(lastModificationDate);
newTrackedEntity.setCreationUser(username);
+ } else {
+ //In case of update we make sure we keep the creation metadata from the DB info
+ AbstractTrackedObject sourceTrackedEntity = (AbstractTrackedObject) sourceEntity;
+ newTrackedEntity.setCreationDate(sourceTrackedEntity.getCreationDate());
+ newTrackedEntity.setCreationUser(sourceTrackedEntity.getCreationUser());
}
newTrackedEntity.setLastModificationDate(lastModificationDate);
newTrackedEntity.setLastModificationUser(username);
diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutAccessor.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutAccessor.java
new file mode 100644
index 000000000..009b9fedf
--- /dev/null
+++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutAccessor.java
@@ -0,0 +1,33 @@
+package step.core.reporting;
+
+import step.core.accessors.AbstractAccessor;
+import step.core.accessors.AbstractOrganizableObject;
+import step.core.collections.Collection;
+import step.core.collections.Filters;
+import step.core.collections.SearchOrder;
+import step.core.collections.filters.Or;
+import step.core.reporting.model.ReportLayout;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static step.core.collections.Order.ASC;
+import static step.core.reporting.model.ReportLayout.FIELD_VISIBILITY;
+import static step.core.reporting.model.ReportLayout.ReportLayoutVisibility.Preset;
+import static step.core.reporting.model.ReportLayout.ReportLayoutVisibility.Shared;
+
+public class ReportLayoutAccessor extends AbstractAccessor {
+
+ public ReportLayoutAccessor(Collection collectionDriver) {
+ super(collectionDriver);
+ }
+
+ public List getAccessibleReportLayoutsDefinitions(String userName) {
+ Or ownerOrShared = Filters.or(List.of(Filters.equals(FIELD_VISIBILITY, Preset.name()), Filters.equals(FIELD_VISIBILITY, Shared.name()), Filters.equals("creationUser", userName)));
+ return this.getCollectionDriver()
+ .find(ownerOrShared, new SearchOrder(ATTRIBUTES_FIELD_NAME + "." + AbstractOrganizableObject.NAME, ASC.numeric), null, null, 0)
+ .peek(reportLayout -> reportLayout.layout = null)
+ .collect(Collectors.toList());
+ }
+
+}
diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutPlugin.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutPlugin.java
new file mode 100644
index 000000000..ffddf3845
--- /dev/null
+++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutPlugin.java
@@ -0,0 +1,99 @@
+package step.core.reporting;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.bson.types.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import step.core.GlobalContext;
+import step.core.accessors.AbstractOrganizableObject;
+import step.core.accessors.DefaultJacksonMapperProvider;
+import step.core.collections.Collection;
+import step.core.collections.Filters;
+import step.core.deployment.WebApplicationConfigurationManager;
+import step.core.entities.Entity;
+import step.core.entities.EntityConstants;
+import step.core.plugins.AbstractControllerPlugin;
+import step.core.plugins.Plugin;
+import step.core.reporting.model.ReportLayout;
+import step.core.reporting.model.ReportLayoutJson;
+import step.framework.server.tables.Table;
+import step.framework.server.tables.TableRegistry;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Plugin
+public class ReportLayoutPlugin extends AbstractControllerPlugin {
+
+ private static final Logger logger = LoggerFactory.getLogger(ReportLayoutPlugin.class);
+
+ public static final String DEFAULT_REPORT_LAYOUT = "Default";
+ public static final String PRESET_FOLDER_PATH_CONFIG_KEY = "plugins.reporting.layouts.presets.folder";
+ public static final String PRESET_FOLDER_PATH_DEFAULT = "../plugins/reporting/layouts";
+ public static final String DEFAULT_LAYOUT_ID_CONFIG_KEY = "plugins.reporting.layouts.default.id";
+ public static final String DEFAULT_LAYOUT_ID_DEFAULT = "69b010aeec94534eb48176db";
+
+ private ReportLayoutAccessor reportLayoutAccessor;
+ private String defaultLayoutId;
+
+ @Override
+ public void serverStart(GlobalContext context) throws Exception {
+ super.serverStart(context);
+ TableRegistry tableRegistry = context.require(TableRegistry.class);
+ //Create accessor
+ Collection reportLayoutCollection = context.getCollectionFactory().getCollection(EntityConstants.reportLayouts, ReportLayout.class);
+ reportLayoutAccessor = new ReportLayoutAccessor(reportLayoutCollection);
+ context.put(ReportLayoutAccessor.class, reportLayoutAccessor);
+ //Register entity
+ context.getEntityManager().register(new Entity<>(EntityConstants.reportLayouts, reportLayoutAccessor, ReportLayout.class));
+ //Register Table, table only return layout metadata not the layout itself
+ tableRegistry.register(EntityConstants.reportLayouts, new Table<>(reportLayoutCollection, ReportLayoutServices.REPORT_LAYOUT_RIGHT + "-read", false).withResultItemTransformer((reportLayout, session) -> {
+ reportLayout.layout = null;
+ return reportLayout;
+ }));
+ //Register Services
+ context.getServiceRegistrationCallback().registerService(ReportLayoutServices.class);
+
+ //Add default layout ID to UI configuration
+ defaultLayoutId = context.getConfiguration().getProperty(DEFAULT_LAYOUT_ID_CONFIG_KEY, DEFAULT_LAYOUT_ID_DEFAULT);
+ WebApplicationConfigurationManager configurationManager = context.require(WebApplicationConfigurationManager.class);
+ configurationManager.registerHook(s -> Map.of(DEFAULT_LAYOUT_ID_CONFIG_KEY, defaultLayoutId));
+
+ }
+
+ @Override
+ public void initializeData(GlobalContext context) throws Exception {
+ super.initializeData(context);
+ // Drop all existing presets - the folder is the source of truth at startup
+ reportLayoutAccessor.getCollectionDriver().remove(
+ Filters.equals(ReportLayout.FIELD_VISIBILITY, ReportLayout.ReportLayoutVisibility.Preset.name()));
+
+ // Load presets from the configured folder
+ File presetsFolder = new File(context.getConfiguration().getProperty(PRESET_FOLDER_PATH_CONFIG_KEY, PRESET_FOLDER_PATH_DEFAULT));
+ if (presetsFolder.exists() && presetsFolder.isDirectory()) {
+ ObjectMapper objectMapper = DefaultJacksonMapperProvider.getObjectMapper();
+ File[] jsonFiles = presetsFolder.listFiles((dir, name) -> name.endsWith(".json"));
+ if (jsonFiles != null) {
+ for (File jsonFile : jsonFiles) {
+ try {
+ ReportLayoutJson layoutJson = objectMapper.readValue(jsonFile, ReportLayoutJson.class);
+ if (ObjectId.isValid(layoutJson.id)) {
+ ReportLayout reportLayout = new ReportLayout(layoutJson.layout, ReportLayout.ReportLayoutVisibility.Preset);
+ reportLayout.addAttribute(AbstractOrganizableObject.NAME, layoutJson.name);
+ reportLayout.setId(new ObjectId(layoutJson.id));
+ reportLayoutAccessor.save(reportLayout);
+ } else {
+ logger.error("Invalid json file: {}, the id {} has been tempered with and is not a valid ObjectId", jsonFile.getAbsolutePath(), layoutJson.id);
+ }
+ } catch (Exception e) {
+ logger.error("Failed to load preset layout from file '{}'", jsonFile.getAbsolutePath(), e);
+ }
+ }
+ }
+ } else {
+ logger.warn("The configured presets folder '{}' does not exist or is not a directory.", presetsFolder.getAbsolutePath());
+ }
+ }
+}
diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutServices.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutServices.java
new file mode 100644
index 000000000..461bf72f2
--- /dev/null
+++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/ReportLayoutServices.java
@@ -0,0 +1,187 @@
+package step.core.reporting;
+
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.PostConstruct;
+import jakarta.inject.Singleton;
+import jakarta.ws.rs.*;
+import jakarta.ws.rs.core.MediaType;
+import org.apache.poi.ss.formula.functions.T;
+import step.controller.services.entities.AbstractEntityServices;
+import step.core.accessors.AbstractOrganizableObject;
+import step.core.deployment.AuthorizationException;
+import step.core.entities.EntityConstants;
+import step.core.reporting.model.ReportLayout;
+import step.core.reporting.model.ReportLayoutJson;
+import step.framework.server.security.Secured;
+import step.framework.server.security.SecuredContext;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+
+@Singleton
+@Path("/report-layout")
+@Tag(name = "ReportLayout")
+@Tag(name = "Entity=ReportLayout")
+@SecuredContext(key = "entity", value = ReportLayoutServices.REPORT_LAYOUT_RIGHT)
+public class ReportLayoutServices extends AbstractEntityServices {
+
+ private static final String SHARED_RIGHT_SUFFIX = "shared";
+ public static final String READ_RIGHT = "read";
+ public static final String DELETE_RIGHT = "delete";
+ public static final String WRITE_RIGHT = "write";
+ public static final String REPORT_LAYOUT_RIGHT = "reportLayout";
+
+ private ReportLayoutAccessor reportLayoutAccessor;
+
+ public ReportLayoutServices() {
+ super(EntityConstants.reportLayouts);
+ }
+
+ @PostConstruct
+ public void init() throws Exception {
+ super.init();
+ reportLayoutAccessor = getContext().require(ReportLayoutAccessor.class);
+ }
+
+ @Override
+ public ReportLayout get(String id) {
+ ReportLayout reportLayout = super.get(id);
+ checkLayoutRight(reportLayout, READ_RIGHT);
+ return reportLayout;
+ }
+
+ @Operation(operationId = "exportLayout", description = "Export the report layout to its Json representation")
+ @GET
+ @Path("/{id}/json")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Secured(right = "{entity}-read")
+ public ReportLayoutJson exportLayout(@PathParam("id") String id) {
+ ReportLayout reportLayout = getEntity(id);
+ checkLayoutRight(reportLayout, READ_RIGHT);
+ return new ReportLayoutJson(reportLayout);
+ }
+
+ @Override
+ public List findByIds(List ids) {
+ List byIds = super.findByIds(ids);
+ byIds.forEach(r -> checkLayoutRight(r, READ_RIGHT));
+ return byIds;
+ }
+
+ @Override
+ public Map findNamesByIds(List ids) {
+ return reportLayoutAccessor.findByIds(ids).peek(l -> checkLayoutRight(l, READ_RIGHT)).collect(Collectors.toMap(a -> a.getId().toHexString(), a ->
+ a.getAttribute(AbstractOrganizableObject.NAME)
+ ));
+ }
+
+ @Override
+ public List findManyByAttributes(Map attributes) {
+ return super.findManyByAttributes(attributes).stream().filter(this::canReadLayout).collect(Collectors.toList());
+ }
+
+ private boolean canReadLayout(ReportLayout r) {
+ try {
+ checkLayoutRight(r, READ_RIGHT);
+ return true;
+ } catch (AuthorizationException e) {
+ return false;
+ }
+ }
+
+ @Operation(description = "Returns all accessible report layouts.")
+ @GET
+ @Path("/list")
+ @Produces(MediaType.APPLICATION_JSON)
+ @Secured(right="{entity}-read")
+ public List getAllReportLayouts() {
+ return reportLayoutAccessor.getAccessibleReportLayoutsDefinitions(getSession().getUser().getUsername());
+ }
+
+ @Override
+ public ReportLayout save(ReportLayout reportLayout) {
+ //Only check additional specific rights when updating layout
+ Optional.ofNullable(super.get(reportLayout.getId().toHexString())).ifPresent(entity -> checkLayoutRight(entity, WRITE_RIGHT));
+ return super.save(reportLayout);
+ }
+
+ private void checkLayoutRight(ReportLayout reportLayout, String right) {
+ if (ReportLayout.ReportLayoutVisibility.Preset.equals(reportLayout.visibility)) {
+ //Preset layouts can be read by any user with the base right, modifications are never allowed
+ if (!READ_RIGHT.equals(right)) {
+ throw new AuthorizationException("Modifying a preset layout is not allowed.");
+ }
+ } else {
+ //If the current user is the owner, he is always allowed (if he has the base access right role)
+ if (!reportLayout.getCreationUser().equals(this.getSession().getUser().getUsername())) {
+ if (ReportLayout.ReportLayoutVisibility.Shared.equals(reportLayout.visibility)) {
+ //Base read right automatically grant access to reading shared dashboard, write and delete require specific rights
+ if (!READ_RIGHT.equals(right)) {
+ //Check specific access right for shared layouts
+ checkRights(REPORT_LAYOUT_RIGHT + "-" + SHARED_RIGHT_SUFFIX + "-" + right);
+ }
+ } else {
+ //The layout isn't shared, the current user is not the owner, and he doesn't have the "all" right
+ throw new AuthorizationException("This is a private layout owned by " + reportLayout.getCreationUser() + ", you have no permission to " + right + " it.");
+ }
+ }
+ }
+ }
+
+ @Override
+ public void delete(String id) {
+ ReportLayout reportLayout = getEntity(id);
+ checkLayoutRight(reportLayout, DELETE_RIGHT);
+ super.delete(id);
+ }
+
+ @Override
+ protected ReportLayout cloneEntity(ReportLayout entity) {
+ ReportLayout clone = super.cloneEntity(entity);
+ clone.visibility = ReportLayout.ReportLayoutVisibility.Private;
+ return clone;
+ }
+
+ @Override
+ public ReportLayout restoreVersion(String id, String versionId) {
+ ReportLayout reportLayout = getEntity(id);
+ checkLayoutRight(reportLayout, WRITE_RIGHT);
+ return super.restoreVersion(id, versionId);
+ }
+
+ @Override
+ public void setLocked(String id, Boolean locked) {
+ ReportLayout reportLayout = getEntity(id);
+ checkLayoutRight(reportLayout, WRITE_RIGHT);
+ super.setLocked(id, locked);
+ }
+
+ @Operation(operationId = "shareReportLayout", description = "Share this report layout with other users")
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Secured(right = "{entity}-write")
+ @Path("{id}/share")
+ public void shareReportLayout(@PathParam("id") String id) {
+ changeLayoutVisibility(id, ReportLayout.ReportLayoutVisibility.Shared);
+ }
+
+ private void changeLayoutVisibility(String id, ReportLayout.ReportLayoutVisibility visibility) {
+ ReportLayout reportLayout = getEntity(id);
+ reportLayout.visibility = visibility;
+ save(reportLayout);
+ }
+
+ @Operation(operationId = "unshareReportLayout", description = "Unshare this report layout")
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Secured(right = "{entity}-write")
+ @Path("{id}/unshare")
+ public void unshareReportLayout(@PathParam("id") String id) {
+ changeLayoutVisibility(id, ReportLayout.ReportLayoutVisibility.Private);
+ }
+}
diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayout.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayout.java
new file mode 100644
index 000000000..5f35b59b3
--- /dev/null
+++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayout.java
@@ -0,0 +1,24 @@
+package step.core.reporting.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.json.JsonObject;
+import step.core.accessors.AbstractTrackedObject;
+
+public class ReportLayout extends AbstractTrackedObject {
+
+ public enum ReportLayoutVisibility {
+ Preset, Private, Shared
+ }
+
+ public static final String FIELD_VISIBILITY = "visibility";
+
+ public JsonObject layout;
+ public ReportLayoutVisibility visibility;
+
+ @JsonCreator
+ public ReportLayout(@JsonProperty("layout") JsonObject layout, @JsonProperty(value = FIELD_VISIBILITY, defaultValue = "Private") ReportLayoutVisibility visibility) {
+ this.layout = layout;
+ this.visibility = visibility != null ? visibility : ReportLayoutVisibility.Private;
+ }
+}
diff --git a/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayoutJson.java b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayoutJson.java
new file mode 100644
index 000000000..c5af5b7f6
--- /dev/null
+++ b/step-controller/step-controller-server/src/main/java/step/core/reporting/model/ReportLayoutJson.java
@@ -0,0 +1,26 @@
+package step.core.reporting.model;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.json.JsonObject;
+import step.core.accessors.AbstractOrganizableObject;
+
+public class ReportLayoutJson {
+
+ public final String id;
+ public final String name;
+ public final JsonObject layout;
+
+ @JsonCreator
+ public ReportLayoutJson(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("layout") JsonObject layout) {
+ this.id = id;
+ this.name = name;
+ this.layout = layout;
+ }
+
+ public ReportLayoutJson(ReportLayout reportLayout) {
+ this.id = reportLayout.getId().toString();
+ this.name = reportLayout.getAttribute(AbstractOrganizableObject.NAME);
+ this.layout = reportLayout.layout;
+ }
+}
diff --git a/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutAccessorTest.java b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutAccessorTest.java
new file mode 100644
index 000000000..fce675d4c
--- /dev/null
+++ b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutAccessorTest.java
@@ -0,0 +1,107 @@
+package step.core.reporting;
+
+import org.junit.Before;
+import org.junit.Test;
+import step.core.accessors.AbstractOrganizableObject;
+import step.core.collections.inmemory.InMemoryCollectionFactory;
+import step.core.reporting.model.ReportLayout;
+
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.Assert.*;
+
+public class ReportLayoutAccessorTest {
+
+ private ReportLayoutAccessor accessor;
+
+ @Before
+ public void setUp() {
+ InMemoryCollectionFactory factory = new InMemoryCollectionFactory(new Properties());
+ accessor = new ReportLayoutAccessor(factory.getCollection("reportLayouts", ReportLayout.class));
+ }
+
+ private ReportLayout saveLayout(String name, ReportLayout.ReportLayoutVisibility visibility, String user) {
+ ReportLayout layout = new ReportLayout(null, visibility);
+ layout.addAttribute(AbstractOrganizableObject.NAME, name);
+ layout.setCreationUser(user);
+ return accessor.save(layout);
+ }
+
+ // --- getAccessibleReportLayoutsDefinitions ---
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_includesPresets() {
+ saveLayout("GlobalPreset", ReportLayout.ReportLayoutVisibility.Preset, null);
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("anyUser");
+
+ assertEquals(1, layouts.size());
+ assertEquals("GlobalPreset", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ }
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_includesSharedLayouts() {
+ saveLayout("SharedLayout", ReportLayout.ReportLayoutVisibility.Shared, "alice");
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("bob");
+
+ assertEquals(1, layouts.size());
+ assertEquals("SharedLayout", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ }
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_includesOwnPrivateLayout() {
+ saveLayout("AlicePrivate", ReportLayout.ReportLayoutVisibility.Private, "alice");
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("alice");
+
+ assertEquals(1, layouts.size());
+ assertEquals("AlicePrivate", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ }
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_excludesOtherUsersPrivateLayout() {
+ saveLayout("AlicePrivate", ReportLayout.ReportLayoutVisibility.Private, "alice");
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("bob");
+
+ assertTrue(layouts.isEmpty());
+ }
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_combinesAllVisibleTypes() {
+ saveLayout("Preset", ReportLayout.ReportLayoutVisibility.Preset, null);
+ saveLayout("Shared", ReportLayout.ReportLayoutVisibility.Shared, "alice");
+ saveLayout("BobPrivate", ReportLayout.ReportLayoutVisibility.Private, "bob");
+ saveLayout("AlicePrivate", ReportLayout.ReportLayoutVisibility.Private, "alice");
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("bob");
+
+ assertEquals(3, layouts.size()); // preset + shared + bob's own private
+ }
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_stripsLayoutField() {
+ saveLayout("Preset", ReportLayout.ReportLayoutVisibility.Preset, null);
+ saveLayout("OwnPrivate", ReportLayout.ReportLayoutVisibility.Private, "alice");
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("alice");
+
+ layouts.forEach(l -> assertNull("layout field should be stripped", l.layout));
+ }
+
+ @Test
+ public void getAccessibleReportLayoutsDefinitions_sortedByName() {
+ saveLayout("Z Layout", ReportLayout.ReportLayoutVisibility.Preset, null);
+ saveLayout("A Layout", ReportLayout.ReportLayoutVisibility.Preset, null);
+ saveLayout("M Layout", ReportLayout.ReportLayoutVisibility.Preset, null);
+
+ List layouts = accessor.getAccessibleReportLayoutsDefinitions("anyUser");
+
+ assertEquals(3, layouts.size());
+ assertEquals("A Layout", layouts.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ assertEquals("M Layout", layouts.get(1).getAttribute(AbstractOrganizableObject.NAME));
+ assertEquals("Z Layout", layouts.get(2).getAttribute(AbstractOrganizableObject.NAME));
+ }
+}
diff --git a/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutPluginTest.java b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutPluginTest.java
new file mode 100644
index 000000000..86d95f848
--- /dev/null
+++ b/step-controller/step-controller-server/src/test/java/step/core/reporting/ReportLayoutPluginTest.java
@@ -0,0 +1,327 @@
+package step.core.reporting;
+
+import ch.exense.commons.app.Configuration;
+import org.bson.types.ObjectId;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+import step.core.GlobalContext;
+import step.core.accessors.AbstractOrganizableObject;
+import step.core.collections.inmemory.InMemoryCollectionFactory;
+import step.core.deployment.WebApplicationConfigurationManager;
+import step.core.entities.EntityManager;
+import step.core.reporting.model.ReportLayout;
+import step.framework.server.ServiceRegistrationCallback;
+import step.framework.server.tables.TableRegistry;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.List;
+import java.util.Properties;
+import java.util.stream.Collectors;
+
+import static org.junit.Assert.*;
+
+public class ReportLayoutPluginTest {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ private GlobalContext context;
+ private ReportLayoutPlugin plugin;
+ private ReportLayoutAccessor accessor;
+
+ @Before
+ public void setUp() throws Exception {
+ context = new GlobalContext();
+ context.setCollectionFactory(new InMemoryCollectionFactory(new Properties()));
+ context.put(TableRegistry.class, new TableRegistry());
+ context.setEntityManager(new EntityManager());
+ context.setServiceRegistrationCallback(Mockito.mock(ServiceRegistrationCallback.class));
+ context.setConfiguration(new Configuration());
+ context.put(WebApplicationConfigurationManager.class, new WebApplicationConfigurationManager());
+
+ plugin = new ReportLayoutPlugin();
+ plugin.serverStart(context);
+ accessor = context.require(ReportLayoutAccessor.class);
+ }
+
+ private void setPresetsFolder(File folder) {
+ context.getConfiguration().putProperty(
+ ReportLayoutPlugin.PRESET_FOLDER_PATH_CONFIG_KEY, folder.getAbsolutePath());
+ }
+
+ /** Writes a preset JSON file with a fresh ObjectId. Returns the generated id. */
+ private String writePresetFile(File folder, String fileName, String name) throws IOException {
+ String id = new ObjectId().toHexString();
+ writePresetFileWithId(folder, fileName, name, id, "{\"type\":\"test\"}");
+ return id;
+ }
+
+ private void writePresetFileWithId(File folder, String fileName, String name, String id, String layoutJson) throws IOException {
+ File file = new File(folder, fileName);
+ try (FileWriter writer = new FileWriter(file)) {
+ writer.write("{\"id\":\"" + id + "\",\"name\":\"" + name + "\",\"layout\":" + layoutJson + "}");
+ }
+ }
+
+ private List getAllPresets() {
+ return accessor.getCollectionDriver()
+ .find(step.core.collections.Filters.equals(
+ ReportLayout.FIELD_VISIBILITY,
+ ReportLayout.ReportLayoutVisibility.Preset.name()),
+ null, null, null, 0)
+ .collect(Collectors.toList());
+ }
+
+ // --- initializeData with valid folder ---
+
+ @Test
+ public void initializeData_singleValidFile_createsPreset() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ writePresetFile(folder, "layout1.json", "My Preset");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals(1, presets.size());
+ assertEquals("My Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ assertEquals(ReportLayout.ReportLayoutVisibility.Preset, presets.get(0).visibility);
+ }
+
+ @Test
+ public void initializeData_multipleValidFiles_createsAllPresets() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ writePresetFile(folder, "alpha.json", "Alpha");
+ writePresetFile(folder, "beta.json", "Beta");
+ writePresetFile(folder, "gamma.json", "Gamma");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals(3, presets.size());
+ List names = presets.stream()
+ .map(p -> p.getAttribute(AbstractOrganizableObject.NAME))
+ .collect(Collectors.toList());
+ assertTrue(names.contains("Alpha"));
+ assertTrue(names.contains("Beta"));
+ assertTrue(names.contains("Gamma"));
+ }
+
+ @Test
+ public void initializeData_preservesIdFromJsonFile() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ String fixedId = new ObjectId().toHexString();
+ writePresetFileWithId(folder, "layout.json", "Fixed Id Preset", fixedId, "{\"type\":\"test\"}");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals(1, presets.size());
+ assertEquals(fixedId, presets.get(0).getId().toHexString());
+ }
+
+ @Test
+ public void initializeData_nonJsonFilesIgnored() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ writePresetFile(folder, "layout.json", "Valid Preset");
+ new File(folder, "readme.txt").createNewFile();
+ new File(folder, "layout.xml").createNewFile();
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals(1, presets.size());
+ assertEquals("Valid Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ }
+
+ // --- initializeData drops and recreates presets on each startup ---
+
+ @Test
+ public void initializeData_dropsExistingPresetsBeforeLoading() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ writePresetFile(folder, "initial.json", "Initial Preset");
+ setPresetsFolder(folder);
+ plugin.initializeData(context);
+ assertEquals(1, getAllPresets().size());
+
+ // Replace file content and re-run initializeData (simulates restart)
+ new File(folder, "initial.json").delete();
+ writePresetFile(folder, "updated.json", "Updated Preset");
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals(1, presets.size());
+ assertEquals("Updated Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ }
+
+ @Test
+ public void initializeData_idempotent_sameIdEachRestart() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ String fixedId = new ObjectId().toHexString();
+ writePresetFileWithId(folder, "layout.json", "Stable Preset", fixedId, "{\"type\":\"test\"}");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals("Second run should not duplicate the preset", 1, presets.size());
+ assertEquals(fixedId, presets.get(0).getId().toHexString());
+ }
+
+ @Test
+ public void initializeData_emptyFolder_dropsAllExistingPresets() throws Exception {
+ ReportLayout existing = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Preset);
+ existing.addAttribute(AbstractOrganizableObject.NAME, "Old Preset");
+ accessor.save(existing);
+ assertEquals(1, getAllPresets().size());
+
+ File emptyFolder = tempFolder.newFolder("empty-presets");
+ setPresetsFolder(emptyFolder);
+
+ plugin.initializeData(context);
+
+ assertTrue("All presets should be dropped when folder is empty", getAllPresets().isEmpty());
+ }
+
+ @Test
+ public void initializeData_doesNotDropNonPresetLayouts() throws Exception {
+ ReportLayout privateLayout = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Private);
+ privateLayout.addAttribute(AbstractOrganizableObject.NAME, "My Private");
+ privateLayout.setCreationUser("alice");
+ accessor.save(privateLayout);
+
+ ReportLayout sharedLayout = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Shared);
+ sharedLayout.addAttribute(AbstractOrganizableObject.NAME, "My Shared");
+ sharedLayout.setCreationUser("bob");
+ accessor.save(sharedLayout);
+
+ File folder = tempFolder.newFolder("presets");
+ writePresetFile(folder, "preset.json", "New Preset");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ assertNotNull(accessor.get(privateLayout.getId()));
+ assertNotNull(accessor.get(sharedLayout.getId()));
+ assertEquals(1, getAllPresets().size());
+ }
+
+ // --- initializeData with missing / invalid folder ---
+
+ @Test
+ public void initializeData_missingFolder_createsNoPresets() throws Exception {
+ context.getConfiguration().putProperty(
+ ReportLayoutPlugin.PRESET_FOLDER_PATH_CONFIG_KEY,
+ "/nonexistent/path/to/presets");
+
+ plugin.initializeData(context);
+
+ assertTrue("No presets should be created when folder does not exist", getAllPresets().isEmpty());
+ }
+
+ @Test
+ public void initializeData_missingFolder_dropsExistingPresets() throws Exception {
+ ReportLayout existing = new ReportLayout(null, ReportLayout.ReportLayoutVisibility.Preset);
+ existing.addAttribute(AbstractOrganizableObject.NAME, "Existing Preset");
+ accessor.save(existing);
+
+ context.getConfiguration().putProperty(
+ ReportLayoutPlugin.PRESET_FOLDER_PATH_CONFIG_KEY,
+ "/nonexistent/path/to/presets");
+
+ plugin.initializeData(context);
+
+ // The drop step runs unconditionally before the folder existence check
+ assertTrue("Existing presets are dropped even when folder is missing", getAllPresets().isEmpty());
+ }
+
+ // --- id validation ---
+
+ @Test
+ public void initializeData_fileWithMissingId_isSkipped() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ try (FileWriter writer = new FileWriter(new File(folder, "no-id.json"))) {
+ writer.write("{\"name\":\"No Id Preset\",\"layout\":{\"type\":\"test\"}}");
+ }
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ assertTrue("Preset with missing id should be skipped", getAllPresets().isEmpty());
+ }
+
+ @Test
+ public void initializeData_fileWithInvalidId_isSkipped() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ try (FileWriter writer = new FileWriter(new File(folder, "bad-id.json"))) {
+ writer.write("{\"id\":\"not-a-valid-objectid\",\"name\":\"Bad Id Preset\",\"layout\":{\"type\":\"test\"}}");
+ }
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ assertTrue("Preset with invalid id should be skipped", getAllPresets().isEmpty());
+ }
+
+ @Test
+ public void initializeData_invalidJsonFile_skipsItAndLoadsOthers() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ try (FileWriter writer = new FileWriter(new File(folder, "broken.json"))) {
+ writer.write("{ this is not valid json }");
+ }
+ writePresetFile(folder, "valid.json", "Valid Preset");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals("Valid preset should still be loaded despite broken file", 1, presets.size());
+ assertEquals("Valid Preset", presets.get(0).getAttribute(AbstractOrganizableObject.NAME));
+ }
+
+ @Test
+ public void initializeData_allInvalidFiles_createsNoPresets() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ try (FileWriter writer = new FileWriter(new File(folder, "broken1.json"))) {
+ writer.write("not json at all");
+ }
+ try (FileWriter writer = new FileWriter(new File(folder, "no-id.json"))) {
+ writer.write("{\"name\":\"Missing Id\",\"layout\":{}}");
+ }
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ assertTrue(getAllPresets().isEmpty());
+ }
+
+ // --- preset layout content ---
+
+ @Test
+ public void initializeData_presetLayoutHasCorrectContent() throws Exception {
+ File folder = tempFolder.newFolder("presets");
+ String id = new ObjectId().toHexString();
+ writePresetFileWithId(folder, "layout.json", "Dashboard", id, "{\"columns\":3,\"rows\":2}");
+ setPresetsFolder(folder);
+
+ plugin.initializeData(context);
+
+ List presets = getAllPresets();
+ assertEquals(1, presets.size());
+ ReportLayout preset = presets.get(0);
+ assertEquals("Dashboard", preset.getAttribute(AbstractOrganizableObject.NAME));
+ assertEquals(ReportLayout.ReportLayoutVisibility.Preset, preset.visibility);
+ assertEquals(id, preset.getId().toHexString());
+ assertNotNull("Layout JSON should be parsed and stored", preset.layout);
+ }
+}
diff --git a/step-core-model/src/main/java/step/core/entities/EntityConstants.java b/step-core-model/src/main/java/step/core/entities/EntityConstants.java
index 677aa5c85..571fcfd31 100644
--- a/step-core-model/src/main/java/step/core/entities/EntityConstants.java
+++ b/step-core-model/src/main/java/step/core/entities/EntityConstants.java
@@ -14,4 +14,5 @@ public class EntityConstants {
public final static String metricTypes = "metricTypes";
public final static String dashboards = "dashboards";
public final static String bookmarks = "bookmarks";
+ public final static String reportLayouts = "reportLayouts";
}