feat(home): move Setup to admin and omit unpermitted home content #20
No files matched your search
@@ -7,6 +7,7 @@ Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic
|
||||
### Fixed
|
||||
|
||||
- QBit metadata now reports the Maven artifact version, including release versions, from a filtered build resource.
|
||||
- The Data Analysis home no longer shows the engine Setup card or "Unavailable: READ permission required." shells. Setup is admin-only (hidden from everyone else) and off the home; hosts place `DataAnalysisQBitProducer.SYSTEM_WIDGETS` on their Admin area. Home widgets the user cannot read are omitted from metadata, and unreadable groups are left out of the overview.
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ package com.kingsrook.qbits.dataanalysis;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.dataanalysis.api.DataAnalysisApi;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -45,6 +47,13 @@ public class DataAnalysisQBitProducer implements QBitMetaDataProducer<DataAnalys
|
||||
public static final String ARTIFACT_ID = "qbit-data-analysis";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
// Widgets that show engine configuration. They are registered with the qbit //
|
||||
// (admin-only, hidden from everyone else) but kept off the business home; the //
|
||||
// host places them on its Admin area, e.g. withWidgets(SYSTEM_WIDGETS). //
|
||||
/////////////////////////////////////////////////////////////////////////////////
|
||||
public static final List<String> SYSTEM_WIDGETS = List.of(DataAnalysisHome.SETUP);
|
||||
|
||||
private DataAnalysisQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
@@ -28,14 +28,17 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitConfig;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisQBitProducer;
|
||||
import com.kingsrook.qbits.dataanalysis.DataAnalysisRuntime;
|
||||
import com.kingsrook.qbits.dataanalysis.engine.DrillFilters;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
|
||||
import com.kingsrook.qbits.dataanalysis.saved.SavedAnalyses;
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.UseOtherPermissionNameCustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
@@ -54,6 +57,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
@@ -82,7 +86,39 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
*******************************************************************************/
|
||||
public static List<QWidgetMetaData> widgets()
|
||||
{
|
||||
return (List.of(widget(SETUP, "Setup", WidgetType.TABLE), widget(), widget(STARRED, "Starred analyses", WidgetType.TABLE), widget(FIRING, "Firing watches", WidgetType.TABLE), widget(RECENT, "My recent runs", WidgetType.TABLE)));
|
||||
return (List.of(setupWidget(), widget().withPermissionRules(readRules(DataAnalysisTables.SAVED)),
|
||||
widget(STARRED, "Starred analyses", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.SAVED)),
|
||||
widget(FIRING, "Firing watches", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.WATCH)),
|
||||
widget(RECENT, "My recent runs", WidgetType.TABLE).withPermissionRules(readRules(DataAnalysisTables.RUN))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Names of the widgets placed on the business home. Setup is system content and
|
||||
** is deliberately absent; hosts place {@link DataAnalysisQBitProducer#SYSTEM_WIDGETS}
|
||||
** on their Admin area instead.
|
||||
*******************************************************************************/
|
||||
public static List<String> homeWidgetNames()
|
||||
{
|
||||
return (widgets().stream().map(QWidgetMetaData::getName).filter(name -> !DataAnalysisQBitProducer.SYSTEM_WIDGETS.contains(name)).toList());
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** The Setup card shows engine configuration, so only Data Analysis admins get it;
|
||||
** everyone else has it omitted from metadata.
|
||||
*******************************************************************************/
|
||||
private static QWidgetMetaData setupWidget()
|
||||
{
|
||||
return (widget(SETUP, "Setup", WidgetType.TABLE).withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withDenyBehavior(DenyBehavior.HIDDEN).withCustomPermissionChecker(UseOtherPermissionNameCustomPermissionChecker.build(DataAnalysisPermissions.ADMIN))));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** A data widget is omitted from metadata for users who cannot read its table.
|
||||
*******************************************************************************/
|
||||
private static QPermissionRules readRules(String table)
|
||||
{
|
||||
return (new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION).withDenyBehavior(DenyBehavior.HIDDEN)
|
||||
.withCustomPermissionChecker(TableReadWidgetPermissionChecker.build(table)));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -101,7 +137,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
public static QAppMetaData app(DataAnalysisQBitConfig config, List<QTableMetaData> tables)
|
||||
{
|
||||
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("insights")).withWidgets(widgets().stream().map(QWidgetMetaData::getName).toList())
|
||||
.withIcon(new QIcon("insights")).withWidgets(homeWidgetNames())
|
||||
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
|
||||
.withSection(new QAppSection().withName("analyses").withLabel("Analyses").withIcon(new QIcon("insights")).withTables(DataAnalysisTables.NAV_TABLES));
|
||||
app.setParentAppName(config.getParentAppName());
|
||||
@@ -197,46 +233,45 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** One standard group per subject, with real counts or an unavailable explanation.
|
||||
** One standard group per subject the viewer can read; unreadable subjects are left out.
|
||||
*******************************************************************************/
|
||||
public static MultiStatisticsData statistics(Instant now) throws QException
|
||||
{
|
||||
List<MultiStatisticsData.StatisticsGroupData> groups = new ArrayList<>();
|
||||
var saved = group("Saved analyses", DataAnalysisTables.SAVED);
|
||||
if(canRead(DataAnalysisTables.SAVED))
|
||||
{
|
||||
var saved = group("Saved analyses");
|
||||
List<SavedAnalyses.Entry> entries = DataAnalysisRuntime.saved().list();
|
||||
String user = SavedAnalyses.user();
|
||||
saved.withStatisticList(List.of(stat("Mine", (int) entries.stream().filter(e -> e.owner().equals(user)).count(), DataAnalysisTables.SAVED),
|
||||
stat("Shared", (int) entries.stream().filter(e -> Boolean.TRUE.equals(e.shared())).count(), DataAnalysisTables.SAVED),
|
||||
stat("Starred", (int) entries.stream().filter(SavedAnalyses.Entry::starred).count(), DataAnalysisTables.SAVED)));
|
||||
groups.add(saved);
|
||||
}
|
||||
groups.add(saved);
|
||||
var watches = group("Watches", DataAnalysisTables.WATCH);
|
||||
if(canRead(DataAnalysisTables.WATCH))
|
||||
{
|
||||
var watches = group("Watches");
|
||||
watches.withStatisticList(List.of(stat("Enabled", count(DataAnalysisTables.WATCH, equal("enabled", true)), DataAnalysisTables.WATCH),
|
||||
stat("Firing", count(DataAnalysisTables.WATCH, equal("state", "FIRING")), DataAnalysisTables.WATCH)));
|
||||
groups.add(watches);
|
||||
}
|
||||
groups.add(watches);
|
||||
var runs = group("My runs", DataAnalysisTables.RUN);
|
||||
if(canRead(DataAnalysisTables.RUN))
|
||||
{
|
||||
var runs = group("My runs");
|
||||
runs.withStatisticList(List.of(stat("Last 7 days", count(DataAnalysisTables.RUN, equal("user_id", SavedAnalyses.user())
|
||||
.withCriteria("started_at", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS))
|
||||
.withCriteria("started_at", QCriteriaOperator.LESS_THAN_OR_EQUALS, now)), DataAnalysisTables.RUN)));
|
||||
groups.add(runs);
|
||||
}
|
||||
groups.add(runs);
|
||||
return (new MultiStatisticsData("Analyses", groups));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Empty/denied data are different messages, not synthetic zeroes.
|
||||
** A group is only built for a readable subject, so its subheader is always true.
|
||||
*******************************************************************************/
|
||||
private static MultiStatisticsData.StatisticsGroupData group(String label, String table)
|
||||
private static MultiStatisticsData.StatisticsGroupData group(String label)
|
||||
{
|
||||
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of())
|
||||
.withSubheader(canRead(table) ? "Visible to you" : "Unavailable: READ permission required."));
|
||||
return (new MultiStatisticsData.StatisticsGroupData().withHeader(label).withStatisticList(List.of()).withSubheader("Visible to you"));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -257,7 +292,7 @@ public class DataAnalysisHome extends AbstractWidgetRenderer
|
||||
{
|
||||
columns.add(new TableData.Column(i == 0 ? "html" : "text", headers[i], "c" + i, null, "left"));
|
||||
}
|
||||
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(canRead(table) ? empty : "Unavailable: READ permission required.")
|
||||
return (new TableData(label, columns, new ArrayList<>()).withNoRowsFoundHTML(empty)
|
||||
.withLinkText("View all").withLinkURL(QContext.getQInstance().getTablePath(table)).withRowsPerPage(10));
|
||||
}
|
||||
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.dataanalysis.app;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.collections.MapBuilder;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A widget is available only to sessions that can READ the table it summarizes.
|
||||
** Contributes no catalog entry of its own: the table's READ permission is the
|
||||
** permission.
|
||||
*******************************************************************************/
|
||||
public class TableReadWidgetPermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
|
||||
{
|
||||
private static final String TABLE_NAME_PROPERTY = "tableName";
|
||||
|
||||
private String tableName;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Code reference for the given table.
|
||||
*******************************************************************************/
|
||||
public static QCodeReferenceWithProperties build(String tableName)
|
||||
{
|
||||
return (new QCodeReferenceWithProperties(TableReadWidgetPermissionChecker.class, MapBuilder.of(TABLE_NAME_PROPERTY, tableName)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the table name from the code reference properties.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
|
||||
{
|
||||
Map<String, Serializable> properties = withProperties.getProperties();
|
||||
tableName = ValueUtils.getValueAsString(properties.get(TABLE_NAME_PROPERTY));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deny unless the session may READ the table.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
AbstractTableActionInput tableInput = new AbstractTableActionInput();
|
||||
tableInput.setTableName(tableName);
|
||||
PermissionsHelper.checkTablePermissionThrowing(tableInput, TablePermissionSubType.READ);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.dataanalysis.app.DataAnalysisHome;
|
||||
import com.kingsrook.qbits.dataanalysis.model.DataAnalysisTables;
|
||||
import com.kingsrook.qbits.dataanalysis.permissions.DataAnalysisPermissions;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.MultiStatisticsData;
|
||||
@@ -69,12 +72,43 @@ class DataAnalysisHomeTest
|
||||
}
|
||||
}
|
||||
QContext.init(instance, TestSupport.user("alice"));
|
||||
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).allSatisfy(g ->
|
||||
assertThat(DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData()).isEmpty();
|
||||
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).doesNotContain("Unavailable");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setupIsOffTheBusinessHomeAndAdminOnly() throws Exception
|
||||
{
|
||||
var instance = TestSupport.instance();
|
||||
assertThat(DataAnalysisHome.homeWidgetNames()).doesNotContain(DataAnalysisHome.SETUP).containsExactly(DataAnalysisHome.HOME_WIDGET,
|
||||
DataAnalysisHome.STARRED, DataAnalysisHome.FIRING, DataAnalysisHome.RECENT);
|
||||
assertThat(instance.getApp("dataAnalysis").getWidgets()).doesNotContain(DataAnalysisHome.SETUP);
|
||||
assertThat(DataAnalysisQBitProducer.SYSTEM_WIDGETS).containsExactly(DataAnalysisHome.SETUP);
|
||||
assertThat(instance.getWidgets()).containsKey(DataAnalysisHome.SETUP);
|
||||
|
||||
QContext.init(instance, TestSupport.admin());
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), DataAnalysisHome.SETUP)).isTrue();
|
||||
assertThat(DataAnalysisHome.setup().getRows()).hasSize(5);
|
||||
assertThat(new DataAnalysisHome().render(new RenderWidgetInput().withWidgetMetaData(instance.getWidget(DataAnalysisHome.SETUP))).getWidgetData()).isInstanceOf(TableData.class);
|
||||
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions(DataAnalysisPermissions.SAVED_READ));
|
||||
assertThat(PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), DataAnalysisHome.SETUP)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void savedReadOnlyUserGetsNoLockedShells() throws Exception
|
||||
{
|
||||
var instance = TestSupport.instance();
|
||||
QContext.init(instance, TestSupport.user("alice").withPermissions(DataAnalysisPermissions.SAVED_READ));
|
||||
List<String> visible = DataAnalysisHome.homeWidgetNames().stream().filter(name -> PermissionsHelper.hasWidgetPermission(new AbstractActionInput(), name)).toList();
|
||||
assertThat(visible).containsExactly(DataAnalysisHome.HOME_WIDGET, DataAnalysisHome.STARRED);
|
||||
for(String name : visible)
|
||||
{
|
||||
assertThat(g.getStatisticList()).isEmpty();
|
||||
assertThat(g.getSubheader()).contains("Unavailable");
|
||||
});
|
||||
assertThat(((TableData) DataAnalysisHome.render(DataAnalysisHome.FIRING, Instant.now())).getNoRowsFoundHTML()).contains("Unavailable");
|
||||
var data = DataAnalysisHome.render(name, Instant.now());
|
||||
assertThat(com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(data)).doesNotContain("Unavailable").doesNotContain("permission required");
|
||||
}
|
||||
var groups = DataAnalysisHome.statistics(Instant.now()).getStatisticsGroupData();
|
||||
assertThat(groups).extracting(g -> g.getHeader()).containsExactly("Saved analyses");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user