Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package com.dotmarketing.startup.runonce;

import com.dotcms.exception.ExceptionUtil;
import com.dotmarketing.business.CacheLocator;
import com.dotmarketing.common.db.DotConnect;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.startup.StartupTask;
import com.dotmarketing.util.Logger;
import com.dotmarketing.util.UUIDUtil;
import com.dotmarketing.util.UtilMethods;

import java.util.List;
import java.util.Map;

import static com.dotmarketing.util.PortletID.SITES;
import static com.dotmarketing.util.PortletID.USAGE;

/**
* Adds the custom 'Usage' portlet to the System menu, if it doesn't exist anywhere in the system.
* Falls back to Marketing menu, or the menu containing the Sites portlet if System is not available.
*
* @author Denis Santos
* @since Feb 6th, 2026
*/
public class Task260206AddUsagePortletToMenu implements StartupTask {

@Override
public boolean forceRun() {
try {
final String layoutID = this.getMenuGroupForPortlet();
if (UtilMethods.isNotSet(layoutID)) {
Logger.warn(this, "The 'Usage' portlet could not be automatically added to any of the expected Menu Groups. " +
"Please add it manually");
return false;
}
final int count = new DotConnect()
.setSQL("SELECT COUNT(portlet_id) AS count FROM cms_layouts_portlets WHERE portlet_id = ?")
.addParam(USAGE.toString())
.getInt("count");
return count == 0;
} catch (final DotDataException e) {
Logger.error(this, String.format("An error occurred when adding the 'Usage' portlet. " +
"Please add it manually: %s", ExceptionUtil.getErrorMessage(e)), e);
}
return false;
}

/**
* Adds the custom {@code Usage} portlet to the appropriate Menu Group.
*
* @throws DotDataException An error occurred when adding the 'Usage' portlet.
*/
@Override
public void executeUpgrade() throws DotDataException {
Logger.info(this, "Adding the 'Usage' portlet to existing Menu Group(s)");
final String layoutID = this.getMenuGroupForPortlet();
if (null != layoutID && !layoutID.isEmpty()) {
final boolean isLayoutMissingUsagePortlet = 0 == new DotConnect()
.setSQL("SELECT COUNT(portlet_id) AS count FROM cms_layouts_portlets WHERE layout_id = ? AND portlet_id = ?")
.addParam(layoutID)
.addParam(USAGE.toString())
.getInt("count");
if (isLayoutMissingUsagePortlet) {
final int portletOrder = new DotConnect()
.setSQL("SELECT max(portlet_order) AS portlet_order FROM cms_layouts_portlets WHERE layout_id = ?")
.setMaxRows(1)
.addParam(layoutID)
.getInt("portlet_order");
new DotConnect()
.setSQL("INSERT INTO cms_layouts_portlets(id, layout_id, portlet_id, portlet_order) VALUES (?, ?, ?, ?)")
.addParam(UUIDUtil.uuid())
.addParam(layoutID)
.addParam(USAGE.toString())
.addParam(portletOrder + 1)
.loadResult();
}
CacheLocator.getLayoutCache().clearCache();
Logger.info(this, "The 'Usage' portlet has been added to the main menu successfully!");
} else {
Logger.error(this, "The 'Usage' portlet could not be added to any Menu Group. " +
"Please add it manually");
}
}

/**
* Returns the Layout ID; i.e., menu group, for the Menu Groups that are meant to hold the
* {@code Usage} portlet, depending on whether they're present or not. The order
* of priority is the following:
* <ol>
* <li>Look for the {@code System} group.</li>
* <li>If not present, look for the {@code Marketing} group.</li>
* <li>If not present, fall back to the group containing the {@code Sites} portlet.</li>
* </ol>
*
* @return The Layout ID that the new portlet will be added to.
*
* @throws DotDataException An error occurred while querying the database.
*/
private String getMenuGroupForPortlet() throws DotDataException {
// Try System layout first
List<Map<String, Object>> results = new DotConnect()
.setSQL("SELECT id FROM cms_layout WHERE LOWER(layout_name) = 'system'")
.loadObjectResults();

if (!results.isEmpty()) {
String layoutId = results.get(0).getOrDefault("id", "").toString();
if (UtilMethods.isSet(layoutId)) {
return layoutId;
}
}

// Try Marketing layout second
results = new DotConnect()
.setSQL("SELECT id FROM cms_layout WHERE LOWER(layout_name) = 'marketing'")
.loadObjectResults();

if (!results.isEmpty()) {
String layoutId = results.get(0).getOrDefault("id", "").toString();
if (UtilMethods.isSet(layoutId)) {
return layoutId;
}
}

// Fall back to layout containing SITES portlet
results = new DotConnect()
.setSQL("SELECT layout_id FROM cms_layouts_portlets WHERE portlet_id = ?")
.addParam(SITES.toString())
.loadObjectResults();

if (!results.isEmpty()) {
return results.get(0).getOrDefault("layout_id", "").toString();
}

Logger.warn(this, "No suitable layout found for Usage portlet");
return null;
}

}
5 changes: 3 additions & 2 deletions dotCMS/src/main/java/com/dotmarketing/util/PortletID.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ public enum PortletID {
VANITY_URLS,
WEB_EVENT_REGISTRATIONS,
WEB_FORMS,
WORKFLOW,
WORKFLOW,
WORKFLOW_SCHEMES,
LOCALES,
ANALYTICS_DASHBOARD;
ANALYTICS_DASHBOARD,
USAGE;

private final String url;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
import com.dotmarketing.startup.runonce.Task251029RemoveContentTypesLegacyPortletFromLayouts;
import com.dotmarketing.startup.runonce.Task251103AddStylePropertiesColumnInMultiTree;
import com.dotmarketing.startup.runonce.Task251212AddVersionColumnIndicesTable;
import com.dotmarketing.startup.runonce.Task260206AddUsagePortletToMenu;
import com.google.common.collect.ImmutableList;

import java.util.ArrayList;
Expand Down Expand Up @@ -594,6 +595,7 @@ public static List<Class<?>> getStartupRunOnceTaskClasses() {
.add(Task251029RemoveContentTypesLegacyPortletFromLayouts.class)
.add(Task251103AddStylePropertiesColumnInMultiTree.class)
.add(Task251212AddVersionColumnIndicesTable.class)
.add(Task260206AddUsagePortletToMenu.class)
.build();

return ret.stream().sorted(classNameComparator).collect(Collectors.toList());
Expand Down
2 changes: 2 additions & 0 deletions dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import com.dotmarketing.startup.runonce.Task250826AddIndexesToUniqueFieldsTableTest;
import com.dotmarketing.startup.runonce.Task251103AddStylePropertiesColumnInMultiTreeTest;
import com.dotmarketing.startup.runonce.Task251212AddVersionColumnIndicesTableTest;
import com.dotmarketing.startup.runonce.Task260206AddUsagePortletToMenuTest;
import org.junit.runner.RunWith;
import org.junit.runners.Suite;

Expand Down Expand Up @@ -60,6 +61,7 @@
StoryBlockValidationTest.class,
StoryBlockUtilTest.class,
Task251212AddVersionColumnIndicesTableTest.class,
Task260206AddUsagePortletToMenuTest.class,
})

public class MainSuite3a {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.dotmarketing.startup.runonce;

import com.dotcms.util.IntegrationTestInitService;
import com.dotmarketing.common.db.DotConnect;
import com.dotmarketing.exception.DotDataException;
import com.dotmarketing.util.Logger;
import org.junit.BeforeClass;
import org.junit.Test;

import static com.dotmarketing.util.PortletID.USAGE;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

/**
* Verifies that the {@link Task260206AddUsagePortletToMenu} Upgrade Task runs as expected.
*
* @author Daniel Colina
* @since Feb 6th, 2026
*/
public class Task260206AddUsagePortletToMenuTest {
Comment thread
dsantos-dcms marked this conversation as resolved.
Comment thread
dsantos-dcms marked this conversation as resolved.

@BeforeClass
public static void prepare() throws Exception {
// Setting web app environment
IntegrationTestInitService.getInstance().init();
}

/**
* <ul>
* <li><b>Method to test:
* </b>{@link Task260206AddUsagePortletToMenu#executeUpgrade()}</li>
* <li><b>Given Scenario: </b>The Usage portlet does not exist in any layout.</li>
* <li><b>Expected Result: </b>The task runs, adds the portlet, and subsequent calls
* return false for forceRun().</li>
* </ul>
*/
@Test
public void upgradeTaskExecution() throws Exception {

deleteUsagePortlet();

final Task260206AddUsagePortletToMenu upgradeTask = new Task260206AddUsagePortletToMenu();

assertTrue("The 'Usage' portlet was explicitly deleted before, so the UT must always run",
upgradeTask.forceRun());
upgradeTask.executeUpgrade();
assertFalse("The 'Usage' portlet has already been added, so the UT must NOT run again",
upgradeTask.forceRun());
}

/**
* <ul>
* <li><b>Method to test:
* </b>{@link Task260206AddUsagePortletToMenu#executeUpgrade()}</li>
* <li><b>Given Scenario: </b>Runs the UT twice.</li>
* <li><b>Expected Result: </b>The UT must be executable as many times as desired without
* failure. This verifies idempotency.</li>
* </ul>
*/
@Test
public void checkUpgradeTaskIdempotency() throws DotDataException {

deleteUsagePortlet();
final Task260206AddUsagePortletToMenu task = new Task260206AddUsagePortletToMenu();

task.executeUpgrade();
// Run again to verify idempotency; i.e., no Java exception/error is thrown
task.executeUpgrade();

assertFalse("After running twice, forceRun() should return false", task.forceRun());
}

/**
* <ul>
* <li><b>Method to test:
* </b>{@link Task260206AddUsagePortletToMenu#forceRun()}</li>
* <li><b>Given Scenario: </b>Test that forceRun() gracefully handles cases where no
* suitable layout exists (System, Marketing, or Sites portlet layout).</li>
* <li><b>Expected Result: </b>The method should not throw an exception and should handle
* the missing layout scenario properly by returning false and logging a warning.</li>
* </ul>
*/
@Test
public void testForceRunHandlesMissingLayouts() throws Exception {
// Store original layouts to restore later
final String backupSystem = new DotConnect()
.setSQL("SELECT layout_name FROM cms_layout WHERE LOWER(layout_name) = 'system'")
.getString("layout_name");
final String backupMarketing = new DotConnect()
.setSQL("SELECT layout_name FROM cms_layout WHERE LOWER(layout_name) = 'marketing'")
.getString("layout_name");

try {
// Temporarily rename layouts to simulate missing layouts
if (backupSystem != null) {
new DotConnect()
.setSQL("UPDATE cms_layout SET layout_name = 'temp_system' WHERE LOWER(layout_name) = 'system'")
.loadResult();
}
if (backupMarketing != null) {
new DotConnect()
.setSQL("UPDATE cms_layout SET layout_name = 'temp_marketing' WHERE LOWER(layout_name) = 'marketing'")
.loadResult();
}

final Task260206AddUsagePortletToMenu upgradeTask = new Task260206AddUsagePortletToMenu();

// This should not throw an exception and should return false or true
// depending on whether the Sites portlet layout is found
upgradeTask.forceRun();

} finally {
// Restore original layout names
if (backupSystem != null) {
new DotConnect()
.setSQL("UPDATE cms_layout SET layout_name = 'System' WHERE layout_name = 'temp_system'")
.loadResult();
}
if (backupMarketing != null) {
new DotConnect()
.setSQL("UPDATE cms_layout SET layout_name = 'Marketing' WHERE layout_name = 'temp_marketing'")
.loadResult();
}
}
}

/**
* Attempts to delete the Usage portlet to ensure it doesn't exist before the test begins.
*/
private void deleteUsagePortlet() {
try {
new DotConnect()
.setSQL("DELETE FROM cms_layouts_portlets WHERE portlet_id = ?")
.addParam(USAGE.toString())
.loadResult();
} catch (final Exception e) {
Logger.info(this, "Failed deleting the portlet_id " + USAGE.toString());
}
}

}
Loading